From ea5dbe386f7f10a2b5cf573fcb44443020a33d8f Mon Sep 17 00:00:00 2001 From: kangsiwoo Date: Wed, 18 Mar 2026 22:52:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20(=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=EB=A7=8C=20=EC=A0=91=EA=B7=BC,=20=EA=B6=8C=ED=95=9C?= =?UTF-8?q?=20=EB=AA=A8=EB=93=88=20=EB=B6=84=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/auth/permissions.py: check_project_access, check_project_owner, add_member, remove_member - 모든 프로젝트 관련 엔드포인트에 멤버십 검증 적용 (REST + WebSocket) - GET /api/projects: 내가 멤버인 프로젝트만 반환 - DELETE /api/projects/{id}: owner만 가능 - 멤버 관리 API 추가 (POST/GET/DELETE /api/projects/{id}/members) - 기존 테스트 수정 + 권한 테스트 추가 (102 passed) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/api/channels.py | 14 +++++ app/api/chat.py | 9 ++++ app/api/members.py | 85 ++++++++++++++++++++++++++++++ app/api/notifications.py | 3 ++ app/api/projects.py | 5 +- app/api/teams.py | 3 ++ app/auth/permissions.py | 76 +++++++++++++++++++++++++++ app/main.py | 9 +++- app/services/project_service.py | 8 ++- tests/test_chat.py | 4 +- tests/test_llm_providers_api.py | 4 +- tests/test_projects.py | 93 ++++++++++++++++++++++++++++++++- 12 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 app/api/members.py create mode 100644 app/auth/permissions.py diff --git a/app/api/channels.py b/app/api/channels.py index d5c3de1..fadcc7c 100644 --- a/app/api/channels.py +++ b/app/api/channels.py @@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.dependencies import get_current_user, get_db +from app.auth.permissions import check_project_access +from app.models.team import Team from app.models.user import User from app.schemas.channel import ChannelCreate, ChannelResponse from app.services import project_service @@ -13,6 +15,14 @@ router = APIRouter(prefix="/api/teams/{team_id}/channels", tags=["channels"]) +async def _get_team_project_id(db: AsyncSession, team_id: uuid.UUID) -> uuid.UUID: + """팀의 프로젝트 ID를 조회""" + team = await db.get(Team, team_id) + if not team: + raise HTTPException(status_code=404, detail="Team not found") + return team.project_id + + @router.post("", response_model=ChannelResponse, status_code=status.HTTP_201_CREATED) async def add_channel( team_id: uuid.UUID, @@ -20,6 +30,8 @@ async def add_channel( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + project_id = await _get_team_project_id(db, team_id) + await check_project_access(db, project_id, user.id) channel = await project_service.add_channel(db, team_id, body.name) return channel @@ -30,4 +42,6 @@ async def list_channels( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + project_id = await _get_team_project_id(db, team_id) + await check_project_access(db, project_id, user.id) return await project_service.get_channels(db, team_id) diff --git a/app/api/chat.py b/app/api/chat.py index a2b7736..70c4a41 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -11,6 +11,7 @@ from sqlalchemy import select from app.auth.jwt import decode_token +from app.auth.permissions import check_project_access from app.database import async_session from app.models.channel import Channel from app.models.team import Team @@ -96,6 +97,14 @@ async def websocket_chat( await websocket.close(code=4001, reason="인증 실패") return + # 1.5. 프로젝트 멤버 확인 + try: + async with async_session() as db: + await check_project_access(db, project_id, user.id) + except Exception: + await websocket.close(code=4003, reason="프로젝트 접근 권한이 없습니다") + return + # 2. ConnectionManager 에 등록 project_id_str = str(project_id) await connection_manager.connect(project_id_str, websocket) diff --git a/app/api/members.py b/app/api/members.py new file mode 100644 index 0000000..8841a97 --- /dev/null +++ b/app/api/members.py @@ -0,0 +1,85 @@ +"""프로젝트 멤버 관리 API""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_user, get_db +from app.auth.permissions import add_member, check_project_access, check_project_owner, remove_member +from app.models.project import ProjectMember +from app.models.user import User + +router = APIRouter(prefix="/api/projects/{project_id}/members", tags=["members"]) + + +class MemberAddRequest(BaseModel): + user_id: uuid.UUID + role: str = "member" + + +class MemberResponse(BaseModel): + user_id: uuid.UUID + role: str + username: str + email: str + model_config = {"from_attributes": True} + + +@router.get("", response_model=list[MemberResponse]) +async def list_members( + project_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """멤버 목록 조회 (멤버 전원 가능)""" + await check_project_access(db, project_id, user.id) + result = await db.execute( + select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .options() + ) + members = result.scalars().all() + response = [] + for m in members: + member_user = await db.get(User, m.user_id) + response.append( + MemberResponse( + user_id=m.user_id, + role=m.role, + username=member_user.username if member_user else "unknown", + email=member_user.email if member_user else "unknown", + ) + ) + return response + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def invite_member( + project_id: uuid.UUID, + body: MemberAddRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """멤버 초대 (owner만 가능)""" + await check_project_owner(db, project_id, user.id) + # 대상 유저 존재 확인 + target_user = await db.get(User, body.user_id) + if not target_user: + raise HTTPException(status_code=404, detail="유저를 찾을 수 없습니다") + await add_member(db, project_id, body.user_id, body.role) + return {"detail": "멤버가 추가되었습니다"} + + +@router.delete("/{member_user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def kick_member( + project_id: uuid.UUID, + member_user_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """멤버 제거 (owner만 가능)""" + await check_project_owner(db, project_id, user.id) + await remove_member(db, project_id, member_user_id) diff --git a/app/api/notifications.py b/app/api/notifications.py index 3fba0c6..02883ed 100644 --- a/app/api/notifications.py +++ b/app/api/notifications.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.dependencies import get_current_user, get_db +from app.auth.permissions import check_project_access from app.models.user import User from app.schemas.notification import NotificationCreate, NotificationResponse from app.services import notification_service, project_service @@ -20,6 +21,7 @@ async def create_notification( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + await check_project_access(db, project_id, user.id) project = await project_service.get_project(db, project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") @@ -34,6 +36,7 @@ async def list_notifications( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + await check_project_access(db, project_id, user.id) parsed_cursor: uuid.UUID | None = None if cursor: try: diff --git a/app/api/projects.py b/app/api/projects.py index e5224d8..cdfd0ef 100644 --- a/app/api/projects.py +++ b/app/api/projects.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.dependencies import get_current_user, get_db +from app.auth.permissions import check_project_access, check_project_owner from app.models.user import User from app.schemas.project import ProjectCreate, ProjectDetailResponse, ProjectResponse from app.services import project_service @@ -31,7 +32,7 @@ async def list_projects( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - return await project_service.list_projects(db) + return await project_service.list_projects(db, user.id) @router.get("/{project_id}", response_model=ProjectDetailResponse) @@ -40,6 +41,7 @@ async def get_project( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + await check_project_access(db, project_id, user.id) project = await project_service.get_project(db, project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") @@ -52,6 +54,7 @@ async def delete_project( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + await check_project_owner(db, project_id, user.id) deleted = await project_service.delete_project(db, project_id) if not deleted: raise HTTPException(status_code=404, detail="Project not found") diff --git a/app/api/teams.py b/app/api/teams.py index f2a3f8c..7ba3c8b 100644 --- a/app/api/teams.py +++ b/app/api/teams.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.dependencies import get_current_user, get_db +from app.auth.permissions import check_project_access from app.models.user import User from app.schemas.team import TeamCreate, TeamDetailResponse, TeamResponse from app.services import project_service @@ -20,6 +21,7 @@ async def add_team( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + await check_project_access(db, project_id, user.id) project = await project_service.get_project(db, project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") @@ -33,4 +35,5 @@ async def list_teams( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + await check_project_access(db, project_id, user.id) return await project_service.get_teams(db, project_id) diff --git a/app/auth/permissions.py b/app/auth/permissions.py new file mode 100644 index 0000000..049559f --- /dev/null +++ b/app/auth/permissions.py @@ -0,0 +1,76 @@ +"""프로젝트 권한 핸들링""" + +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.project import ProjectMember + + +async def check_project_access( + db: AsyncSession, project_id: uuid.UUID, user_id: uuid.UUID +) -> ProjectMember: + """프로젝트 멤버인지 확인. 아니면 403.""" + result = await db.execute( + select(ProjectMember).where( + ProjectMember.project_id == project_id, + ProjectMember.user_id == user_id, + ) + ) + member = result.scalar_one_or_none() + if not member: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="프로젝트 접근 권한이 없습니다", + ) + return member + + +async def check_project_owner( + db: AsyncSession, project_id: uuid.UUID, user_id: uuid.UUID +) -> ProjectMember: + """프로젝트 owner인지 확인. 아니면 403.""" + member = await check_project_access(db, project_id, user_id) + if member.role != "owner": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="프로젝트 소유자만 가능합니다", + ) + return member + + +async def add_member( + db: AsyncSession, project_id: uuid.UUID, user_id: uuid.UUID, role: str = "member" +): + """프로젝트에 멤버 추가""" + existing = await db.execute( + select(ProjectMember).where( + ProjectMember.project_id == project_id, + ProjectMember.user_id == user_id, + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="이미 프로젝트 멤버입니다") + db.add(ProjectMember(project_id=project_id, user_id=user_id, role=role)) + await db.commit() + + +async def remove_member( + db: AsyncSession, project_id: uuid.UUID, user_id: uuid.UUID +): + """프로젝트에서 멤버 제거""" + result = await db.execute( + select(ProjectMember).where( + ProjectMember.project_id == project_id, + ProjectMember.user_id == user_id, + ) + ) + member = result.scalar_one_or_none() + if not member: + raise HTTPException(status_code=404, detail="멤버를 찾을 수 없습니다") + if member.role == "owner": + raise HTTPException(status_code=400, detail="소유자는 제거할 수 없습니다") + await db.delete(member) + await db.commit() diff --git a/app/main.py b/app/main.py index 12f610b..e0efc19 100644 --- a/app/main.py +++ b/app/main.py @@ -9,14 +9,18 @@ from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from app.api import auth, channels, chat, devices, notifications, projects, teams -from app.database import engine +from app.api import auth, channels, chat, devices, members, notifications, projects, teams +from app.database import Base, engine logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): + # 앱 시작 시 테이블 자동 생성 (SQLite 파일 DB) + import app.models # noqa: F401 — 모든 모델 등록 + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) yield await engine.dispose() @@ -38,6 +42,7 @@ async def lifespan(app: FastAPI): app.include_router(teams.router) app.include_router(channels.router) app.include_router(notifications.router) +app.include_router(members.router) app.include_router(devices.router) # WebSocket 채팅 diff --git a/app/services/project_service.py b/app/services/project_service.py index 6fdc323..5dfed76 100644 --- a/app/services/project_service.py +++ b/app/services/project_service.py @@ -45,9 +45,13 @@ async def get_project_by_name(db: AsyncSession, name: str) -> Project | None: return result.scalar_one_or_none() -async def list_projects(db: AsyncSession) -> list[Project]: +async def list_projects(db: AsyncSession, user_id: uuid.UUID) -> list[Project]: result = await db.execute( - select(Project).options(selectinload(Project.teams)).order_by(Project.created_at) + select(Project) + .join(ProjectMember) + .where(ProjectMember.user_id == user_id) + .options(selectinload(Project.teams)) + .order_by(Project.created_at) ) return list(result.scalars().all()) diff --git a/tests/test_chat.py b/tests/test_chat.py index 52150d4..644407a 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -10,7 +10,7 @@ from app.auth.jwt import create_access_token from app.models.channel import Channel -from app.models.project import Project +from app.models.project import Project, ProjectMember from app.models.team import Team from app.models.user import User from app.main import app @@ -27,6 +27,8 @@ async def project_with_channel(db: AsyncSession, test_user: User): db.add(project) await db.flush() + db.add(ProjectMember(project_id=project.id, user_id=test_user.id, role="owner")) + team = Team(project_id=project.id, name="테스트팀") db.add(team) await db.flush() diff --git a/tests/test_llm_providers_api.py b/tests/test_llm_providers_api.py index ff39a42..a12132f 100644 --- a/tests/test_llm_providers_api.py +++ b/tests/test_llm_providers_api.py @@ -12,7 +12,7 @@ from app.auth.jwt import create_access_token from app.main import app from app.models.channel import Channel -from app.models.project import Project +from app.models.project import Project, ProjectMember from app.models.team import Team from app.models.user import User from app.services.llm_service import LLMProvider, LLMService @@ -79,6 +79,8 @@ async def project_with_channel(db: AsyncSession, test_user: User): db.add(project) await db.flush() + db.add(ProjectMember(project_id=project.id, user_id=test_user.id, role="owner")) + team = Team(project_id=project.id, name="테스트팀") db.add(team) await db.flush() diff --git a/tests/test_projects.py b/tests/test_projects.py index b175ad9..b72e282 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -1,6 +1,12 @@ """프로젝트/팀/채널 API 테스트""" +import uuid + import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.jwt import create_access_token +from app.models.user import User @pytest.mark.asyncio @@ -27,6 +33,36 @@ async def test_list_projects(client, auth_headers): assert len(resp.json()) == 2 +@pytest.mark.asyncio +async def test_list_projects_only_my_projects(client, auth_headers, db: AsyncSession): + """다른 유저가 만든 프로젝트는 보이지 않는다""" + import bcrypt + # 다른 유저 생성 + hashed = bcrypt.hashpw(b"password123", bcrypt.gensalt()).decode() + other_user = User( + id=uuid.uuid4(), + email="other@example.com", + username="otheruser", + hashed_password=hashed, + ) + db.add(other_user) + await db.commit() + await db.refresh(other_user) + other_token = create_access_token(str(other_user.id)) + other_headers = {"Authorization": f"Bearer {other_token}"} + + # 각각 프로젝트 생성 + await client.post("/api/projects", json={"name": "my-proj"}, headers=auth_headers) + await client.post("/api/projects", json={"name": "other-proj"}, headers=other_headers) + + # 내 프로젝트만 보임 + resp = await client.get("/api/projects", headers=auth_headers) + assert resp.status_code == 200 + projects = resp.json() + assert len(projects) == 1 + assert projects[0]["name"] == "my-proj" + + @pytest.mark.asyncio async def test_get_project_detail(client, auth_headers): create_resp = await client.post("/api/projects", json={"name": "detail-proj"}, headers=auth_headers) @@ -39,6 +75,30 @@ async def test_get_project_detail(client, auth_headers): assert len(data["members"]) == 1 +@pytest.mark.asyncio +async def test_get_project_forbidden(client, auth_headers, db: AsyncSession): + """멤버가 아닌 프로젝트에 접근 시 403""" + import bcrypt + hashed = bcrypt.hashpw(b"password123", bcrypt.gensalt()).decode() + other_user = User( + id=uuid.uuid4(), + email="forbidden@example.com", + username="forbiddenuser", + hashed_password=hashed, + ) + db.add(other_user) + await db.commit() + await db.refresh(other_user) + other_token = create_access_token(str(other_user.id)) + other_headers = {"Authorization": f"Bearer {other_token}"} + + create_resp = await client.post("/api/projects", json={"name": "secret-proj"}, headers=auth_headers) + project_id = create_resp.json()["id"] + + resp = await client.get(f"/api/projects/{project_id}", headers=other_headers) + assert resp.status_code == 403 + + @pytest.mark.asyncio async def test_delete_project(client, auth_headers): create_resp = await client.post("/api/projects", json={"name": "to-delete"}, headers=auth_headers) @@ -47,10 +107,39 @@ async def test_delete_project(client, auth_headers): assert resp.status_code == 204 resp = await client.get(f"/api/projects/{project_id}", headers=auth_headers) - assert resp.status_code == 404 + assert resp.status_code == 403 # no longer a member (project deleted) + + +@pytest.mark.asyncio +async def test_delete_project_non_owner(client, auth_headers, db: AsyncSession): + """owner가 아닌 멤버는 삭제 불가""" + import bcrypt + from app.models.project import ProjectMember + hashed = bcrypt.hashpw(b"password123", bcrypt.gensalt()).decode() + member_user = User( + id=uuid.uuid4(), + email="member@example.com", + username="memberuser", + hashed_password=hashed, + ) + db.add(member_user) + await db.commit() + await db.refresh(member_user) + member_token = create_access_token(str(member_user.id)) + member_headers = {"Authorization": f"Bearer {member_token}"} + + create_resp = await client.post("/api/projects", json={"name": "owner-only"}, headers=auth_headers) + project_id = create_resp.json()["id"] + + # 멤버로 추가 + db.add(ProjectMember(project_id=uuid.UUID(project_id), user_id=member_user.id, role="member")) + await db.commit() + + resp = await client.delete(f"/api/projects/{project_id}", headers=member_headers) + assert resp.status_code == 403 @pytest.mark.asyncio async def test_delete_project_not_found(client, auth_headers): resp = await client.delete("/api/projects/00000000-0000-0000-0000-000000000000", headers=auth_headers) - assert resp.status_code == 404 + assert resp.status_code == 403 # not a member → 403 before 404