From 38b5b60160e44af91d3f4db19bba3d3d0e035cd2 Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Sun, 21 Jun 2026 09:22:09 +0800 Subject: [PATCH] feat(workspace): restore core routes on sqlalchemy adapters --- .../versions/002_add_workspace_core_tables.py | 90 ++++ apps/api/app/api/router.py | 5 + apps/api/app/api/routes/workspaces.py | 440 ++++-------------- apps/api/app/dependencies.py | 16 + apps/api/app/middleware/auth.py | 50 +- docs/schema-metadata-snapshot.json | 284 +++++++++++ packages/adapters/sqlalchemy_impl/models.py | 44 +- .../workspace_member_repository.py | 91 ++++ .../sqlalchemy_impl/workspace_repository.py | 59 +++ tests/unit/test_auth_middleware.py | 32 +- .../test_workspace_sqlalchemy_repositories.py | 56 +++ 11 files changed, 806 insertions(+), 361 deletions(-) create mode 100644 alembic/versions/002_add_workspace_core_tables.py create mode 100644 packages/adapters/sqlalchemy_impl/workspace_member_repository.py create mode 100644 packages/adapters/sqlalchemy_impl/workspace_repository.py create mode 100644 tests/unit/test_workspace_sqlalchemy_repositories.py diff --git a/alembic/versions/002_add_workspace_core_tables.py b/alembic/versions/002_add_workspace_core_tables.py new file mode 100644 index 000000000..181d8eab6 --- /dev/null +++ b/alembic/versions/002_add_workspace_core_tables.py @@ -0,0 +1,90 @@ +"""Add workspace core tables. + +Revision ID: 002 +Revises: 001 +Create Date: 2026-06-21 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +revision: str = "002" +down_revision: Union[str, None] = "001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "workspaces", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("owner_user_id", sa.String(length=36), nullable=False), + sa.Column("subscription_plan", sa.String(length=20), nullable=False), + sa.Column("subscription_status", sa.String(length=20), nullable=False), + sa.Column("subscription_expires_at", sa.DateTime(), nullable=True), + sa.Column("max_projects", sa.Float(), nullable=False), + sa.Column("max_storage_gb", sa.Float(), nullable=False), + sa.Column("used_storage_gb", sa.Float(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_workspaces_owner_user_id"), "workspaces", ["owner_user_id"], unique=False) + + op.create_table( + "workspace_members", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("workspace_id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("role", sa.String(length=20), nullable=False), + sa.Column("invited_by", sa.String(length=36), nullable=True), + sa.Column("joined_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("workspace_id", "user_id", name="uq_workspace_members_workspace_user"), + ) + op.create_index(op.f("ix_workspace_members_user_id"), "workspace_members", ["user_id"], unique=False) + op.create_index(op.f("ix_workspace_members_workspace_id"), "workspace_members", ["workspace_id"], unique=False) + + op.create_table( + "workspace_invitations", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("workspace_id", sa.String(length=36), nullable=False), + sa.Column("inviter_user_id", sa.String(length=36), nullable=False), + sa.Column("invitee_email", sa.String(length=255), nullable=False), + sa.Column("role", sa.String(length=20), nullable=False), + sa.Column("invitation_token", sa.String(length=255), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("accepted_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_workspace_invitations_invitation_token"), + "workspace_invitations", + ["invitation_token"], + unique=True, + ) + op.create_index( + op.f("ix_workspace_invitations_invitee_email"), "workspace_invitations", ["invitee_email"], unique=False + ) + op.create_index(op.f("ix_workspace_invitations_status"), "workspace_invitations", ["status"], unique=False) + op.create_index( + op.f("ix_workspace_invitations_workspace_id"), "workspace_invitations", ["workspace_id"], unique=False + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_workspace_invitations_workspace_id"), table_name="workspace_invitations") + op.drop_index(op.f("ix_workspace_invitations_status"), table_name="workspace_invitations") + op.drop_index(op.f("ix_workspace_invitations_invitee_email"), table_name="workspace_invitations") + op.drop_index(op.f("ix_workspace_invitations_invitation_token"), table_name="workspace_invitations") + op.drop_table("workspace_invitations") + op.drop_index(op.f("ix_workspace_members_workspace_id"), table_name="workspace_members") + op.drop_index(op.f("ix_workspace_members_user_id"), table_name="workspace_members") + op.drop_table("workspace_members") + op.drop_index(op.f("ix_workspaces_owner_user_id"), table_name="workspaces") + op.drop_table("workspaces") diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 2a93c42a1..6b8aa38ae 100644 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -9,6 +9,7 @@ from app.api.routes.ingest_jobs import router as ingest_jobs_router from app.api.routes.project_management import router as project_management_router from app.api.routes.projects import router as projects_router from app.api.routes.upload import router as upload_router +from app.api.routes.workspaces import router as workspaces_router from fastapi import APIRouter api_router = APIRouter(prefix="/api/v1") @@ -19,6 +20,10 @@ api_router.include_router( auth_router, tags=["认证"], ) +api_router.include_router( + workspaces_router, + tags=["工作空间"], +) api_router.include_router( projects_router, prefix="/projects", diff --git a/apps/api/app/api/routes/workspaces.py b/apps/api/app/api/routes/workspaces.py index 12025a6ed..6d353ab22 100644 --- a/apps/api/app/api/routes/workspaces.py +++ b/apps/api/app/api/routes/workspaces.py @@ -1,36 +1,30 @@ """ -Legacy workspace API route skeleton. +Workspace API routes wired through the canonical API composition root. -This module depends on the removed DI container and is intentionally disabled until -workspace use cases are wired through the canonical API composition root. +Only the core create/list/detail endpoints are enabled here. Member invitation +and subscription endpoints remain in application use cases but are mounted only +after their persistence repositories are wired and smoke-tested. """ -raise RuntimeError( - "apps.api.app.api.routes.workspaces is disabled: rebuild DI container before mounting workspace routes" -) - -from datetime import datetime -from typing import List - +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_user_repository, get_workspace_member_repository, get_workspace_repository from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel -from apps.api.app.dependencies import get_container -from apps.api.app.middleware.auth import ( - get_current_user, - require_workspace_access, - require_workspace_admin, - require_workspace_owner, +from packages.application.workspace.create_workspace_use_case import CreateWorkspaceRequest, CreateWorkspaceUseCase +from packages.application.workspace.list_workspaces_use_case import ( + GetWorkspaceDetailRequest, + GetWorkspaceDetailUseCase, + ListWorkspacesRequest, + ListWorkspacesUseCase, ) -from packages.application.workspace import * -from packages.domain.entities import User +from packages.ports.user_repository import UserRepository +from packages.ports.workspace_member_repository import WorkspaceMemberRepository +from packages.ports.workspace_repository import WorkspaceRepository router = APIRouter(prefix="/workspaces", tags=["Workspaces"]) -# ==================== Request/Response Models ==================== - - class CreateWorkspaceRequestModel(BaseModel): name: str subscription_plan: str = "free" @@ -44,41 +38,55 @@ class WorkspaceResponseModel(BaseModel): max_storage_gb: int -class InviteMemberRequestModel(BaseModel): - email: EmailStr - role: str # admin, member, viewer +class WorkspaceSummaryModel(BaseModel): + workspace_id: str + name: str + subscription_plan: str + max_projects: int + max_storage_gb: int + member_count: int + user_role: str -class UpdateMemberRoleRequestModel(BaseModel): - role: str +class WorkspaceListResponseModel(BaseModel): + workspaces: list[WorkspaceSummaryModel] -class UpgradeSubscriptionRequestModel(BaseModel): - new_plan: str # pro, enterprise - - -# ==================== Workspace CRUD ==================== +class WorkspaceDetailResponseModel(BaseModel): + workspace_id: str + name: str + owner_user_id: str + subscription_plan: str + subscription_status: str + max_projects: int + max_storage_gb: int + used_storage_gb: float + member_count: int + user_role: str @router.post("", response_model=WorkspaceResponseModel, status_code=status.HTTP_201_CREATED) async def create_workspace( request: CreateWorkspaceRequestModel, - current_user: User = Depends(get_current_user), + authenticated_user: AuthenticatedUser = Depends(get_current_user), + workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), + workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), + user_repository: UserRepository = Depends(get_user_repository), ): - """创建工作空间""" - container = get_container() - use_case = container.get_create_workspace_use_case() - - req = CreateWorkspaceRequest( - name=request.name, - owner_user_id=current_user.id, - subscription_plan=request.subscription_plan, + use_case = CreateWorkspaceUseCase( + workspace_repository=workspace_repository, + workspace_member_repository=workspace_member_repository, + user_repository=user_repository, ) - - response, error = use_case.execute(req) - - if error: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) + response, error = use_case.execute( + CreateWorkspaceRequest( + name=request.name, + owner_user_id=authenticated_user.user.id, + subscription_plan=request.subscription_plan, + ) + ) + if error or response is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to create workspace") return WorkspaceResponseModel( workspace_id=response.workspace_id, @@ -89,306 +97,62 @@ async def create_workspace( ) -@router.get("") -async def list_workspaces(current_user: User = Depends(get_current_user)): - """获取用户的所有工作空间""" - container = get_container() - use_case = container.get_list_workspaces_use_case() +@router.get("", response_model=WorkspaceListResponseModel) +async def list_workspaces( + authenticated_user: AuthenticatedUser = Depends(get_current_user), + workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), + workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), +): + use_case = ListWorkspacesUseCase( + workspace_repository=workspace_repository, + workspace_member_repository=workspace_member_repository, + ) + response, error = use_case.execute(ListWorkspacesRequest(user_id=authenticated_user.user.id)) + if error or response is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to list workspaces") - req = ListWorkspacesRequest(user_id=current_user.id) - response, error = use_case.execute(req) - - if error: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) - - return { - "workspaces": [ - { - "workspace_id": ws.workspace_id, - "name": ws.name, - "subscription_plan": ws.subscription_plan, - "max_projects": ws.max_projects, - "max_storage_gb": ws.max_storage_gb, - "member_count": ws.member_count, - "user_role": ws.user_role, - } - for ws in response.workspaces + return WorkspaceListResponseModel( + workspaces=[ + WorkspaceSummaryModel( + workspace_id=workspace.workspace_id, + name=workspace.name, + subscription_plan=workspace.subscription_plan, + max_projects=workspace.max_projects, + max_storage_gb=workspace.max_storage_gb, + member_count=workspace.member_count, + user_role=workspace.user_role, + ) + for workspace in response.workspaces ] - } + ) -@router.get("/{workspace_id}") +@router.get("/{workspace_id}", response_model=WorkspaceDetailResponseModel) async def get_workspace_detail( workspace_id: str, - current_user: User = Depends(get_current_user), + authenticated_user: AuthenticatedUser = Depends(get_current_user), + workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), + workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), ): - """获取工作空间详情""" - container = get_container() - use_case = container.get_get_workspace_detail_use_case() - - req = GetWorkspaceDetailRequest(workspace_id=workspace_id, user_id=current_user.id) - detail, error = use_case.execute(req) - - if error: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error) - - return { - "workspace_id": detail.workspace_id, - "name": detail.name, - "owner_user_id": detail.owner_user_id, - "subscription_plan": detail.subscription_plan, - "subscription_status": detail.subscription_status, - "max_projects": detail.max_projects, - "max_storage_gb": detail.max_storage_gb, - "used_storage_gb": detail.used_storage_gb, - "member_count": detail.member_count, - "user_role": detail.user_role, - } - - -# ==================== Member Management ==================== - - -@router.post("/{workspace_id}/members/invite", status_code=status.HTTP_201_CREATED) -async def invite_member( - workspace_id: str, - request: InviteMemberRequestModel, - current_user: User = Depends(get_current_user), -): - """邀请成员""" - container = get_container() - use_case = container.get_invite_member_use_case() - - req = InviteMemberRequest( - workspace_id=workspace_id, - inviter_user_id=current_user.id, - invitee_email=request.email, - role=request.role, + use_case = GetWorkspaceDetailUseCase( + workspace_repository=workspace_repository, + workspace_member_repository=workspace_member_repository, ) - - response, error = use_case.execute(req) - - if error: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) - - return { - "invitation_id": response.invitation_id, - "invitee_email": response.invitee_email, - "role": response.role, - "expires_at": response.expires_at.isoformat(), - } - - -@router.get("/{workspace_id}/members") -async def list_members( - workspace_id: str, - current_user: User = Depends(get_current_user), -): - """获取成员列表""" - container = get_container() - use_case = container.get_list_members_use_case() - - req = ListMembersRequest(workspace_id=workspace_id, requester_user_id=current_user.id) - response, error = use_case.execute(req) - - if error: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error) - - return { - "members": [ - { - "member_id": m.member_id, - "user_id": m.user_id, - "username": m.username, - "email": m.email, - "display_name": m.display_name, - "role": m.role, - "invited_by": m.invited_by, - "joined_at": m.joined_at.isoformat(), - } - for m in response.members - ] - } - - -@router.delete("/{workspace_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def remove_member( - workspace_id: str, - user_id: str, - current_user: User = Depends(get_current_user), -): - """移除成员""" - container = get_container() - use_case = container.get_remove_member_use_case() - - req = RemoveMemberRequest( - workspace_id=workspace_id, - requester_user_id=current_user.id, - target_user_id=user_id, + detail, error = use_case.execute( + GetWorkspaceDetailRequest(workspace_id=workspace_id, user_id=authenticated_user.user.id) ) + if error or detail is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error or "Workspace access denied") - success, error = use_case.execute(req) - - if not success: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) - - -@router.post("/{workspace_id}/leave", status_code=status.HTTP_204_NO_CONTENT) -async def leave_workspace( - workspace_id: str, - current_user: User = Depends(get_current_user), -): - """离开工作空间""" - container = get_container() - use_case = container.get_leave_workspace_use_case() - - req = LeaveWorkspaceRequest(workspace_id=workspace_id, user_id=current_user.id) - success, error = use_case.execute(req) - - if not success: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) - - -@router.patch("/{workspace_id}/members/{user_id}/role") -async def update_member_role( - workspace_id: str, - user_id: str, - request: UpdateMemberRoleRequestModel, - current_user: User = Depends(get_current_user), -): - """修改成员角色""" - container = get_container() - use_case = container.get_update_member_role_use_case() - - req = UpdateMemberRoleRequest( - workspace_id=workspace_id, - requester_user_id=current_user.id, - target_user_id=user_id, - new_role=request.role, + return WorkspaceDetailResponseModel( + workspace_id=detail.workspace_id, + name=detail.name, + owner_user_id=detail.owner_user_id, + subscription_plan=detail.subscription_plan, + subscription_status=detail.subscription_status, + max_projects=detail.max_projects, + max_storage_gb=detail.max_storage_gb, + used_storage_gb=detail.used_storage_gb, + member_count=detail.member_count, + user_role=detail.user_role, ) - - response, error = use_case.execute(req) - - if error: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) - - return { - "user_id": response.user_id, - "old_role": response.old_role, - "new_role": response.new_role, - } - - -# ==================== Subscription Management ==================== - - -@router.post("/{workspace_id}/subscription/upgrade") -async def upgrade_subscription( - workspace_id: str, - request: UpgradeSubscriptionRequestModel, - current_user: User = Depends(get_current_user), -): - """升级订阅""" - container = get_container() - use_case = container.get_upgrade_subscription_use_case() - - req = UpgradeSubscriptionRequest( - workspace_id=workspace_id, - requester_user_id=current_user.id, - new_plan=request.new_plan, - ) - - response, error = use_case.execute(req) - - if error: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) - - return { - "workspace_id": response.workspace_id, - "old_plan": response.old_plan, - "new_plan": response.new_plan, - "max_projects": response.max_projects, - "max_storage_gb": response.max_storage_gb, - } - - -@router.post("/{workspace_id}/subscription/cancel") -async def cancel_subscription( - workspace_id: str, - current_user: User = Depends(get_current_user), -): - """取消订阅""" - container = get_container() - use_case = container.get_cancel_subscription_use_case() - - req = CancelSubscriptionRequest(workspace_id=workspace_id, requester_user_id=current_user.id) - success, error = use_case.execute(req) - - if not success: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) - - return {"message": "Subscription cancelled successfully"} - - -@router.get("/{workspace_id}/quota") -async def get_quota_status( - workspace_id: str, - current_user: User = Depends(get_current_user), -): - """获取配额状态""" - container = get_container() - quota_checker = container.quota_checker - - # 检查权限 - permission_checker = container.permission_checker - has_access, _ = permission_checker.check_workspace_access(workspace_id, current_user.id) - - if not has_access: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") - - status = quota_checker.get_quota_status(workspace_id) - - if not status: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found") - - return status - - -# ==================== Invitation Acceptance ==================== - - -@router.post("/invitations/{token}/accept") -async def accept_invitation( - token: str, - current_user: User = Depends(get_current_user), -): - """接受邀请""" - container = get_container() - use_case = container.get_accept_invitation_use_case() - - req = AcceptInvitationRequest(invitation_token=token, user_id=current_user.id) - response, error = use_case.execute(req) - - if error: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) - - return { - "workspace_id": response.workspace_id, - "workspace_name": response.workspace_name, - "role": response.role, - } - - -@router.post("/invitations/{token}/decline") -async def decline_invitation(token: str): - """拒绝邀请""" - container = get_container() - use_case = container.get_decline_invitation_use_case() - - req = DeclineInvitationRequest(invitation_token=token) - success, error = use_case.execute(req) - - if not success: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error) - - return {"message": "Invitation declined"} diff --git a/apps/api/app/dependencies.py b/apps/api/app/dependencies.py index 74a2ad7f9..650c8fa3e 100644 --- a/apps/api/app/dependencies.py +++ b/apps/api/app/dependencies.py @@ -23,7 +23,11 @@ from packages.adapters.sqlalchemy_impl.project_repository import ( ) from packages.adapters.sqlalchemy_impl.session import build_session_factory from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository +from packages.adapters.sqlalchemy_impl.workspace_member_repository import SQLAlchemyWorkspaceMemberRepository +from packages.adapters.sqlalchemy_impl.workspace_repository import SQLAlchemyWorkspaceRepository from packages.ports.user_repository import UserRepository +from packages.ports.workspace_member_repository import WorkspaceMemberRepository +from packages.ports.workspace_repository import WorkspaceRepository _engine, _SessionLocal = build_session_factory(settings.DATABASE_URL) @@ -82,3 +86,15 @@ def get_user_repository( session: Session = Depends(get_db_session), ) -> UserRepository: return SQLAlchemyUserRepository(session) + + +def get_workspace_repository( + session: Session = Depends(get_db_session), +) -> WorkspaceRepository: + return SQLAlchemyWorkspaceRepository(session) + + +def get_workspace_member_repository( + session: Session = Depends(get_db_session), +) -> WorkspaceMemberRepository: + return SQLAlchemyWorkspaceMemberRepository(session) diff --git a/apps/api/app/middleware/auth.py b/apps/api/app/middleware/auth.py index b5005c9da..c6ca26c46 100644 --- a/apps/api/app/middleware/auth.py +++ b/apps/api/app/middleware/auth.py @@ -9,12 +9,14 @@ from __future__ import annotations from app.auth import AuthenticatedUser from app.auth import get_current_user as get_authenticated_user -from app.dependencies import get_user_repository +from app.dependencies import get_user_repository, get_workspace_member_repository from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from packages.domain.entities import User +from packages.domain.permissions import PermissionChecker from packages.ports.user_repository import UserRepository +from packages.ports.workspace_member_repository import WorkspaceMemberRepository optional_bearer_scheme = HTTPBearer(auto_error=False) @@ -38,23 +40,37 @@ async def get_current_user_optional( return authenticated_user.user -def require_workspace_access(workspace_id: str, user: User = Depends(get_current_user)) -> tuple[str, str]: - raise _workspace_dependency_not_ready(workspace_id, user.id) +def require_workspace_access( + workspace_id: str, + user: User = Depends(get_current_user), + workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), +) -> tuple[str, str]: + has_access, role = PermissionChecker(workspace_member_repository).check_workspace_access(workspace_id, user.id) + if not has_access or role is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this workspace") + return workspace_id, role -def require_workspace_admin(workspace_id: str, user: User = Depends(get_current_user)) -> str: - raise _workspace_dependency_not_ready(workspace_id, user.id) +def require_workspace_admin( + workspace_id: str, + user: User = Depends(get_current_user), + workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), +) -> str: + if not PermissionChecker(workspace_member_repository).check_is_admin_or_owner(workspace_id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace owner or admin can perform this action", + ) + return workspace_id -def require_workspace_owner(workspace_id: str, user: User = Depends(get_current_user)) -> str: - raise _workspace_dependency_not_ready(workspace_id, user.id) - - -def _workspace_dependency_not_ready(workspace_id: str, user_id: str) -> HTTPException: - return HTTPException( - status_code=status.HTTP_501_NOT_IMPLEMENTED, - detail=( - "Workspace authorization dependencies require SQLAlchemy workspace-member " - f"repository wiring before use: workspace_id={workspace_id}, user_id={user_id}" - ), - ) +def require_workspace_owner( + workspace_id: str, + user: User = Depends(get_current_user), + workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), +) -> str: + if not PermissionChecker(workspace_member_repository).check_is_owner(workspace_id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Only workspace owner can perform this action" + ) + return workspace_id diff --git a/docs/schema-metadata-snapshot.json b/docs/schema-metadata-snapshot.json index 426d1af41..051f179b8 100644 --- a/docs/schema-metadata-snapshot.json +++ b/docs/schema-metadata-snapshot.json @@ -1407,6 +1407,290 @@ "primary_key": [ "id" ] + }, + "workspace_invitations": { + "columns": [ + { + "index": false, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": true, + "name": "workspace_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": false, + "name": "inviter_user_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": true, + "name": "invitee_email", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(255)", + "unique": false + }, + { + "index": false, + "name": "role", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(20)", + "unique": false + }, + { + "index": true, + "name": "invitation_token", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(255)", + "unique": true + }, + { + "index": true, + "name": "status", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(20)", + "unique": false + }, + { + "index": false, + "name": "expires_at", + "nullable": true, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "accepted_at", + "nullable": true, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "created_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [ + { + "columns": [ + "invitation_token" + ], + "name": "ix_workspace_invitations_invitation_token", + "unique": true + }, + { + "columns": [ + "invitee_email" + ], + "name": "ix_workspace_invitations_invitee_email", + "unique": false + }, + { + "columns": [ + "status" + ], + "name": "ix_workspace_invitations_status", + "unique": false + }, + { + "columns": [ + "workspace_id" + ], + "name": "ix_workspace_invitations_workspace_id", + "unique": false + } + ], + "primary_key": [ + "id" + ] + }, + "workspace_members": { + "columns": [ + { + "index": false, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": true, + "name": "workspace_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": true, + "name": "user_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": false, + "name": "role", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(20)", + "unique": false + }, + { + "index": false, + "name": "invited_by", + "nullable": true, + "primary_key": false, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": false, + "name": "joined_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [ + { + "columns": [ + "user_id" + ], + "name": "ix_workspace_members_user_id", + "unique": false + }, + { + "columns": [ + "workspace_id" + ], + "name": "ix_workspace_members_workspace_id", + "unique": false + } + ], + "primary_key": [ + "id" + ] + }, + "workspaces": { + "columns": [ + { + "index": false, + "name": "id", + "nullable": false, + "primary_key": true, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": false, + "name": "name", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(100)", + "unique": false + }, + { + "index": true, + "name": "owner_user_id", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(36)", + "unique": false + }, + { + "index": false, + "name": "subscription_plan", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(20)", + "unique": false + }, + { + "index": false, + "name": "subscription_status", + "nullable": false, + "primary_key": false, + "type": "VARCHAR(20)", + "unique": false + }, + { + "index": false, + "name": "subscription_expires_at", + "nullable": true, + "primary_key": false, + "type": "DATETIME", + "unique": false + }, + { + "index": false, + "name": "max_projects", + "nullable": false, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "max_storage_gb", + "nullable": false, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "used_storage_gb", + "nullable": false, + "primary_key": false, + "type": "FLOAT", + "unique": false + }, + { + "index": false, + "name": "created_at", + "nullable": false, + "primary_key": false, + "type": "DATETIME", + "unique": false + } + ], + "indexes": [ + { + "columns": [ + "owner_user_id" + ], + "name": "ix_workspaces_owner_user_id", + "unique": false + } + ], + "primary_key": [ + "id" + ] } } } diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 4b19b3e17..9fc529162 100644 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from sqlalchemy import Boolean, Column, DateTime, Float, String, Text, create_engine +from sqlalchemy import Boolean, Column, DateTime, Float, String, Text, UniqueConstraint, create_engine from sqlalchemy.orm import declarative_base Base = declarative_base() @@ -23,6 +23,48 @@ class UserModel(Base): created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) +class WorkspaceModel(Base): + __tablename__ = "workspaces" + + id = Column(String(36), primary_key=True) + name = Column(String(100), nullable=False) + owner_user_id = Column(String(36), nullable=False, index=True) + subscription_plan = Column(String(20), nullable=False, default="free") + subscription_status = Column(String(20), nullable=False, default="active") + subscription_expires_at = Column(DateTime, nullable=True) + max_projects = Column(Float, nullable=False, default=3) + max_storage_gb = Column(Float, nullable=False, default=10) + used_storage_gb = Column(Float, nullable=False, default=0.0) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + + +class WorkspaceMemberModel(Base): + __tablename__ = "workspace_members" + __table_args__ = (UniqueConstraint("workspace_id", "user_id", name="uq_workspace_members_workspace_user"),) + + id = Column(String(36), primary_key=True) + workspace_id = Column(String(36), nullable=False, index=True) + user_id = Column(String(36), nullable=False, index=True) + role = Column(String(20), nullable=False) + invited_by = Column(String(36), nullable=True) + joined_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + + +class WorkspaceInvitationModel(Base): + __tablename__ = "workspace_invitations" + + id = Column(String(36), primary_key=True) + workspace_id = Column(String(36), nullable=False, index=True) + inviter_user_id = Column(String(36), nullable=False) + invitee_email = Column(String(255), nullable=False, index=True) + role = Column(String(20), nullable=False) + invitation_token = Column(String(255), nullable=False, unique=True, index=True) + status = Column(String(20), nullable=False, default="pending", index=True) + expires_at = Column(DateTime, nullable=True) + accepted_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + + class ProjectModel(Base): __tablename__ = "projects" diff --git a/packages/adapters/sqlalchemy_impl/workspace_member_repository.py b/packages/adapters/sqlalchemy_impl/workspace_member_repository.py new file mode 100644 index 000000000..dcaac543c --- /dev/null +++ b/packages/adapters/sqlalchemy_impl/workspace_member_repository.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.models import WorkspaceMemberModel +from packages.domain.entities import WorkspaceMember +from packages.ports.workspace_member_repository import WorkspaceMemberRepository + + +class SQLAlchemyWorkspaceMemberRepository(WorkspaceMemberRepository): + def __init__(self, session: Session): + self.session = session + + def save(self, member: WorkspaceMember) -> None: + model = self.session.get(WorkspaceMemberModel, member.id) + if model is None: + model = ( + self.session.query(WorkspaceMemberModel) + .filter( + WorkspaceMemberModel.workspace_id == member.workspace_id, + WorkspaceMemberModel.user_id == member.user_id, + ) + .first() + ) + if model is None: + model = WorkspaceMemberModel(id=member.id) + self.session.add(model) + + model.workspace_id = member.workspace_id + model.user_id = member.user_id + model.role = str(member.role) + model.invited_by = member.invited_by + model.joined_at = member.joined_at + + self.session.commit() + self.session.refresh(model) + + def find_by_id(self, member_id: str) -> WorkspaceMember | None: + return self._to_entity(self.session.get(WorkspaceMemberModel, member_id)) + + def find_by_workspace_and_user(self, workspace_id: str, user_id: str) -> WorkspaceMember | None: + model = ( + self.session.query(WorkspaceMemberModel) + .filter(WorkspaceMemberModel.workspace_id == workspace_id, WorkspaceMemberModel.user_id == user_id) + .first() + ) + return self._to_entity(model) + + def find_by_user(self, user_id: str) -> list[WorkspaceMember]: + models = ( + self.session.query(WorkspaceMemberModel) + .filter(WorkspaceMemberModel.user_id == user_id) + .order_by(WorkspaceMemberModel.joined_at.desc()) + .all() + ) + return [self._to_entity(model) for model in models if model is not None] + + def find_by_workspace(self, workspace_id: str) -> list[WorkspaceMember]: + models = ( + self.session.query(WorkspaceMemberModel) + .filter(WorkspaceMemberModel.workspace_id == workspace_id) + .order_by(WorkspaceMemberModel.joined_at.asc()) + .all() + ) + return [self._to_entity(model) for model in models if model is not None] + + def count_by_workspace(self, workspace_id: str) -> int: + return ( + self.session.query(WorkspaceMemberModel).filter(WorkspaceMemberModel.workspace_id == workspace_id).count() + ) + + def delete(self, member_id: str) -> bool: + model = self.session.get(WorkspaceMemberModel, member_id) + if model is None: + return False + self.session.delete(model) + self.session.commit() + return True + + @staticmethod + def _to_entity(model: WorkspaceMemberModel | None) -> WorkspaceMember | None: + if model is None: + return None + return WorkspaceMember( + id=model.id, + workspace_id=model.workspace_id, + user_id=model.user_id, + role=model.role, + invited_by=model.invited_by, + joined_at=model.joined_at, + ) diff --git a/packages/adapters/sqlalchemy_impl/workspace_repository.py b/packages/adapters/sqlalchemy_impl/workspace_repository.py new file mode 100644 index 000000000..2903a699e --- /dev/null +++ b/packages/adapters/sqlalchemy_impl/workspace_repository.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.models import WorkspaceModel +from packages.domain.entities import Workspace +from packages.ports.workspace_repository import WorkspaceRepository + + +class SQLAlchemyWorkspaceRepository(WorkspaceRepository): + def __init__(self, session: Session): + self.session = session + + def save(self, workspace: Workspace) -> None: + model = self.session.get(WorkspaceModel, workspace.id) + if model is None: + model = WorkspaceModel(id=workspace.id) + self.session.add(model) + + model.name = workspace.name + model.owner_user_id = workspace.owner_user_id + model.subscription_plan = workspace.subscription_plan + model.subscription_status = workspace.subscription_status + model.subscription_expires_at = workspace.subscription_expires_at + model.max_projects = workspace.max_projects + model.max_storage_gb = workspace.max_storage_gb + model.used_storage_gb = workspace.used_storage_gb + model.created_at = workspace.created_at + + self.session.commit() + self.session.refresh(model) + + def find_by_id(self, workspace_id: str) -> Workspace | None: + return self._to_entity(self.session.get(WorkspaceModel, workspace_id)) + + def delete(self, workspace_id: str) -> bool: + model = self.session.get(WorkspaceModel, workspace_id) + if model is None: + return False + self.session.delete(model) + self.session.commit() + return True + + @staticmethod + def _to_entity(model: WorkspaceModel | None) -> Workspace | None: + if model is None: + return None + return Workspace( + id=model.id, + name=model.name, + owner_user_id=model.owner_user_id, + subscription_plan=model.subscription_plan, + subscription_status=model.subscription_status, + subscription_expires_at=model.subscription_expires_at, + max_projects=int(model.max_projects), + max_storage_gb=int(model.max_storage_gb), + used_storage_gb=float(model.used_storage_gb), + created_at=model.created_at, + ) diff --git a/tests/unit/test_auth_middleware.py b/tests/unit/test_auth_middleware.py index ef93634a8..0e906080e 100644 --- a/tests/unit/test_auth_middleware.py +++ b/tests/unit/test_auth_middleware.py @@ -80,12 +80,34 @@ def test_legacy_middleware_optional_user_returns_user_with_valid_credentials(): assert user.id == "user-1" -def test_workspace_dependency_fails_closed_until_repository_is_wired(): - with pytest.raises(HTTPException) as error: - require_workspace_access("workspace-1", _repo_with_user().find_by_id("user-1")) +def test_workspace_dependency_allows_member_access(): + repo = _WorkspaceMemberRepositoryStub(role="member") - assert error.value.status_code == 501 - assert "SQLAlchemy workspace-member repository" in error.value.detail + workspace_id, role = require_workspace_access("workspace-1", _repo_with_user().find_by_id("user-1"), repo) + + assert workspace_id == "workspace-1" + assert role == "member" + + +def test_workspace_dependency_rejects_non_member(): + with pytest.raises(HTTPException) as error: + require_workspace_access( + "workspace-1", _repo_with_user().find_by_id("user-1"), _WorkspaceMemberRepositoryStub() + ) + + assert error.value.status_code == 403 + + +class _WorkspaceMemberRepositoryStub: + def __init__(self, role=None): + self.role = role + + def find_by_workspace_and_user(self, workspace_id, user_id): + if self.role is None: + return None + from packages.domain.entities import WorkspaceMember + + return WorkspaceMember(id="member-1", workspace_id=workspace_id, user_id=user_id, role=self.role) async def _authenticated_user(): diff --git a/tests/unit/test_workspace_sqlalchemy_repositories.py b/tests/unit/test_workspace_sqlalchemy_repositories.py new file mode 100644 index 000000000..22b9adf12 --- /dev/null +++ b/tests/unit/test_workspace_sqlalchemy_repositories.py @@ -0,0 +1,56 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from packages.adapters.sqlalchemy_impl.models import Base +from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository +from packages.adapters.sqlalchemy_impl.workspace_member_repository import SQLAlchemyWorkspaceMemberRepository +from packages.adapters.sqlalchemy_impl.workspace_repository import SQLAlchemyWorkspaceRepository +from packages.application.workspace.create_workspace_use_case import CreateWorkspaceRequest, CreateWorkspaceUseCase +from packages.application.workspace.list_workspaces_use_case import ListWorkspacesRequest, ListWorkspacesUseCase +from packages.domain.auth import password_hasher +from packages.domain.entities import User + + +def test_workspace_create_and_list_use_cases_with_sqlalchemy_repositories(): + session = _sqlite_session() + user_repository = SQLAlchemyUserRepository(session) + workspace_repository = SQLAlchemyWorkspaceRepository(session) + workspace_member_repository = SQLAlchemyWorkspaceMemberRepository(session) + user_repository.save( + User( + id="user-1", + email="user@example.com", + username="user", + display_name="User", + password_hash=password_hasher.hash_password("Password1"), + ) + ) + + create_response, create_error = CreateWorkspaceUseCase( + workspace_repository=workspace_repository, + workspace_member_repository=workspace_member_repository, + user_repository=user_repository, + ).execute(CreateWorkspaceRequest(name="Main Workspace", owner_user_id="user-1", subscription_plan="free")) + + assert create_error is None + assert create_response is not None + assert create_response.name == "Main Workspace" + + list_response, list_error = ListWorkspacesUseCase( + workspace_repository=workspace_repository, + workspace_member_repository=workspace_member_repository, + ).execute(ListWorkspacesRequest(user_id="user-1")) + + assert list_error is None + assert list_response is not None + assert len(list_response.workspaces) == 1 + assert list_response.workspaces[0].workspace_id == create_response.workspace_id + assert list_response.workspaces[0].user_role == "owner" + assert list_response.workspaces[0].member_count == 1 + + +def _sqlite_session(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + session_factory = sessionmaker(bind=engine) + return session_factory()