Phase 1: OAuth 連線 & 整合設定頁 — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.

Spec: design.md (§4.1, §6, §13.1, §13.2, §15) Master Index: implementation-plan.md

Goal: 讓 tenant admin 可以在「雲端空間整合設定」頁完成 Google Drive OAuth 連線與中斷,後端安全儲存加密 token,UI 顯示連線狀態。不含任何 Drive 資料夾建立或檔案同步。

Architecture: 新增 cloud_integration 模組(DDD 完整一套:api/app/domain/infra),單張 tenant_drive_integrations 表,Fernet 加密 token,OAuth state 存 Redis(CSRF 防護),標準 OAuth 2.0 server-side flow。前端新增 /settings/cloud-integrations 路由與 settings page。

Tech Stack: google-auth-oauthlib / google-api-python-client (僅 userinfo) / cryptography.fernet / Vue3 + PrimeVue


§1

File Structure (Phase 1)

後端新增 / 修改

common/code/
└── grc_error_code.py                         # 修改:新增 9 個 error codes (見 Task 2)

scripts/sql/
└── 2026-MM-DD-google-drive-oauth-integration.sql   # 新增:建表 + GRANT + RLS

api/cloud_integration/
├── __init__.py                               # create_module() Blueprint
├── routes/
│   └── google_drive_integration_route.py     # GET / POST auth-url / GET callback / DELETE / GET status
└── serializers/
    └── google_drive_integration.py           # marshmallow response schema

app/cloud_integration/
├── service/
│   └── google_drive_integration_service.py   # OAuth orchestration (auth_url / handle_callback / disconnect / status)
└── dto/
    └── google_drive_integration_dto.py       # DTO

domain/cloud_integration/
├── entities/
│   └── tenant_drive_integration_entity.py
├── repository/
│   └── tenant_drive_integration_repository.py   # interface
└── service/
    ├── tenant_drive_integration_domain_service.py   # CRUD
    └── token_crypto_service.py               # interface

infra/cloud_integration/
├── models/
│   └── tenant_drive_integration.py           # SQLAlchemy model
├── mapper/
│   └── tenant_drive_integration_mapper.py
├── repository/
│   └── tenant_drive_integration_repo_impl.py
├── crypto/
│   └── fernet_crypto.py                      # TokenCryptoService 實作
└── google_drive/
    └── google_oauth_client.py                # OAuth code exchange / userinfo / revoke

di_containers/cloud_integration/
├── __init__.py
└── cloud_integration_containers.py           # CloudIntegrationContainer

di_containers/
└── containers.py                             # 修改:register CloudIntegrationContainer

config/
└── app_modules.py                            # 修改:append "cloud_integration"

config/
└── config.py                                 # 修改:新增 GOOGLE_DRIVE_OAUTH_* / DRIVE_TOKEN_ENCRYPTION_KEY

pyproject.toml                                # 修改:add google-* + cryptography

前端新增 / 修改

src/config/api/
└── api.js                                    # 修改:append INTEGRATION_GDRIVE_* 常數

src/service/
└── CloudIntegrationService.js                # 新增

src/views/integrations/
└── CloudIntegrationsView.vue                 # 新增主頁

src/components/integrations/
└── GoogleDriveIntegrationCard.vue            # 新增 Drive 卡片

src/router/
└── index.js                                  # 修改:append route

src/lang/
├── zh-TW.js                                  # 修改:i18n 字串
└── en.js                                     # 修改:i18n 字串

src/components/layout/
└── AppMenu.vue (or wherever sidebar)         # 修改:選單加「雲端空間整合設定」(admin only)

Tests

test/cloud_integration/
├── conftest.py                               # fixtures
├── test_token_crypto_service.py              # Fernet 加解密
├── test_tenant_drive_integration_repo.py     # repo CRUD
├── test_google_oauth_client.py               # OAuth client (mock httpx/google-auth)
├── test_google_drive_integration_service.py  # app service (mock client + repo)
└── test_google_drive_integration_route.py    # E2E with Flask test client

§2

Task List

Task 1: 加套件依賴 & 註冊模組

Files:

  • Modify: pyproject.toml
  • Modify: config/app_modules.py
  • Modify: config/config.py
  • Create: api/cloud_integration/__init__.py
  • google-api-python-client = "^2.130.0"
    google-auth-oauthlib = "^1.2.0"
    google-auth-httplib2 = "^0.2.0"
    cryptography = "^42.0.0"  # 已有則跳過
  • GOOGLE_DRIVE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_DRIVE_OAUTH_CLIENT_ID", "")
    GOOGLE_DRIVE_OAUTH_CLIENT_SECRET = os.getenv("GOOGLE_DRIVE_OAUTH_CLIENT_SECRET", "")
    GOOGLE_DRIVE_OAUTH_REDIRECT_URI = os.getenv("GOOGLE_DRIVE_OAUTH_REDIRECT_URI", "")
    DRIVE_TOKEN_ENCRYPTION_KEY = os.getenv("DRIVE_TOKEN_ENCRYPTION_KEY", "")
  • """Cloud Integration Module — Flask Blueprint"""
    import logging
    from flask import Blueprint
    from flask_restful import Api
    
    logger = logging.getLogger("api")
    
    def create_module():
        bp = Blueprint("cloud_integration", __name__, url_prefix="/api/integrations")
        api = Api(bp)
        logger.info("Cloud Integration module registered (empty placeholder)")
        return bp
  • git add pyproject.toml poetry.lock config/app_modules.py config/config.py api/cloud_integration/
    git commit -m "feat(cloud_integration): scaffold module + add google-api-python-client deps"

Task 2: 新增 Error Codes

Files:

  • Modify: common/code/grc_error_code.py
  •     # ── Auth / Admin ─────────────────────────────────────────────────────
        GRC_NOT_ADMIN                            = ("需要 tenant admin 權限",                  "GRC_403021")
    
        # ── Cloud Integration / Google Drive ─────────────────────────────────
        GRC_DRIVE_OAUTH_STATE_INVALID            = ("OAuth 狀態驗證失敗,請重新嘗試授權",        "GRC_400030")
        GRC_DRIVE_OAUTH_NO_REFRESH_TOKEN         = ("Google 未回傳 refresh token,請至帳號授權頁移除舊授權後重試",  "GRC_400032")
        GRC_DRIVE_FILE_OVERSIZED                 = ("Drive 檔案超過大小限制",                  "GRC_400031")  # Phase 3
        GRC_DRIVE_TOKEN_REVOKED                  = ("Drive 連線已失效,請重新授權",             "GRC_401010")
        GRC_FORBIDDEN_DELETE_DRIVE_EVIDENCE      = ("Drive 同步的證據請至 Google Drive 刪除",   "GRC_403020")  # Phase 3
        GRC_DRIVE_INTEGRATION_NOT_FOUND          = ("此 tenant 尚未連線 Google Drive",         "GRC_404030")
        GRC_DRIVE_FOLDER_MAPPING_NOT_FOUND       = ("Drive 資料夾對應不存在",                   "GRC_404031")  # Phase 2
        GRC_DRIVE_INTEGRATION_ALREADY_EXISTS     = ("Google Drive 已連線,請先中斷",            "GRC_409020")
        GRC_DRIVE_WEBHOOK_TOKEN_INVALID          = ("Webhook 驗證失敗",                       "GRC_412020")  # Phase 3
        GRC_DRIVE_API_ERROR                      = ("Google Drive API 錯誤,請稍後再試",        "GRC_500030")
  • git add common/code/grc_error_code.py
    git commit -m "feat(cloud_integration): add Google Drive integration error codes"

Task 3: SQL Migration — tenant_drive_integrations

Files:

  • Create: scripts/sql/<YYYY-MM-DD>-google-drive-oauth-integration.sql
  • -- Date: <YYYY-MM-DD>
    -- Purpose: Google Drive OAuth 連線資訊 per tenant
    
    -- 1. 建立 tenant_drive_integrations 表 (<YYYY-MM-DD>)
    CREATE TABLE IF NOT EXISTS compliance.tenant_drive_integrations (
        id                              SERIAL PRIMARY KEY,
        uid                             UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
        tenant_id                       INTEGER NOT NULL UNIQUE,
        provider                        VARCHAR(20) NOT NULL DEFAULT 'google_drive',
        google_account_email            VARCHAR(255),
        refresh_token_encrypted         TEXT,
        access_token_cache              TEXT,
        access_token_expires_at         TIMESTAMPTZ,
        root_folder_id                  VARCHAR(100),
        drive_change_cursor             VARCHAR(255),
        webhook_channel_id              VARCHAR(100),
        webhook_resource_id             VARCHAR(100),
        webhook_token                   VARCHAR(100),
        webhook_expires_at              TIMESTAMPTZ,
        status                          VARCHAR(20) NOT NULL DEFAULT 'DISCONNECTED',
        connected_by_user_id            INTEGER,
        connected_at                    TIMESTAMPTZ,
        last_sync_at                    TIMESTAMPTZ,
        last_sync_error                 TEXT,
        created_user                    VARCHAR(100),
        updated_user                    VARCHAR(100),
        created_at                      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        updated_at                      TIMESTAMPTZ NOT NULL DEFAULT NOW()
    );
    
    -- 2. 建立 status check constraint (<YYYY-MM-DD>)
    ALTER TABLE compliance.tenant_drive_integrations
        ADD CONSTRAINT chk_tenant_drive_integrations_status
        CHECK (status IN ('CONNECTED', 'EXPIRED', 'REVOKED', 'DISCONNECTED'));
    
    -- 3. 索引 (<YYYY-MM-DD>)
    CREATE INDEX IF NOT EXISTS idx_tenant_drive_integrations_tenant_id
        ON compliance.tenant_drive_integrations(tenant_id);
    CREATE INDEX IF NOT EXISTS idx_tenant_drive_integrations_status
        ON compliance.tenant_drive_integrations(status);
    
    -- 4. 授權給 cm_app (<YYYY-MM-DD>)
    GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.tenant_drive_integrations TO cm_app;
    GRANT USAGE, SELECT ON SEQUENCE compliance.tenant_drive_integrations_id_seq TO cm_app;
    
    -- 5. RLS — tenant 隔離 (<YYYY-MM-DD>)
    ALTER TABLE compliance.tenant_drive_integrations ENABLE ROW LEVEL SECURITY;
    CREATE POLICY tenant_drive_integrations_tenant_isolation
        ON compliance.tenant_drive_integrations
        USING (tenant_id = current_setting('app.tenant_id', true)::INTEGER);
  • psql -h $DB_HOST -U postgres -d compliance_manager -f scripts/sql/<YYYY-MM-DD>-google-drive-oauth-integration.sql

    確認無錯誤。

  • git add scripts/sql/<YYYY-MM-DD>-google-drive-oauth-integration.sql
    git commit -m "feat(cloud_integration): add tenant_drive_integrations table migration"

Task 4: SQLAlchemy Model — TenantDriveIntegration

Files:

  • Create: infra/cloud_integration/__init__.py
  • Create: infra/cloud_integration/models/__init__.py
  • Create: infra/cloud_integration/models/tenant_drive_integration.py
  • import uuid
    from datetime import datetime
    
    from sqlalchemy import Column, Integer, String, Text, DateTime, CheckConstraint
    from sqlalchemy.dialects.postgresql import UUID
    from jedi_common.session.database.db import Base
    
    
    class TenantDriveIntegration(Base):
        __tablename__ = "tenant_drive_integrations"
        __table_args__ = (
            CheckConstraint(
                "status IN ('CONNECTED', 'EXPIRED', 'REVOKED', 'DISCONNECTED')",
                name="chk_tenant_drive_integrations_status",
            ),
            {"schema": "compliance"},
        )
    
        id = Column(Integer, primary_key=True)
        uid = Column(UUID(as_uuid=True), nullable=False, unique=True, default=uuid.uuid4)
        tenant_id = Column(Integer, nullable=False, unique=True)
        provider = Column(String(20), nullable=False, default="google_drive")
        google_account_email = Column(String(255))
        refresh_token_encrypted = Column(Text)
        access_token_cache = Column(Text)
        access_token_expires_at = Column(DateTime(timezone=True))
        root_folder_id = Column(String(100))
        drive_change_cursor = Column(String(255))
        webhook_channel_id = Column(String(100))
        webhook_resource_id = Column(String(100))
        webhook_token = Column(String(100))
        webhook_expires_at = Column(DateTime(timezone=True))
        status = Column(String(20), nullable=False, default="DISCONNECTED")
        connected_by_user_id = Column(Integer)
        connected_at = Column(DateTime(timezone=True))
        last_sync_at = Column(DateTime(timezone=True))
        last_sync_error = Column(Text)
        created_user = Column(String(100))
        updated_user = Column(String(100))
        created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
        updated_at = Column(
            DateTime(timezone=True), nullable=False,
            default=lambda: datetime.now(timezone.utc),
            onupdate=lambda: datetime.now(timezone.utc),
        )

    注意:用 datetime.now(timezone.utc)(Python 3.12+ 慣用),不用 deprecated 的 datetime.utcnow。記得 from datetime import datetime, timezone

  • from infra.cloud_integration.models.tenant_drive_integration import TenantDriveIntegration
    
    def test_tenant_drive_integration_table_name():
        assert TenantDriveIntegration.__tablename__ == "tenant_drive_integrations"
        assert TenantDriveIntegration.__table_args__[-1] == {"schema": "compliance"}
  • git add infra/cloud_integration/ test/cloud_integration/test_tenant_drive_integration_model.py
    git commit -m "feat(cloud_integration): add TenantDriveIntegration ORM model"

Task 5: Domain Entity & Repository Interface

Files:

  • Create: domain/cloud_integration/__init__.py + subdirs
  • Create: domain/cloud_integration/entities/tenant_drive_integration_entity.py
  • Create: domain/cloud_integration/repository/tenant_drive_integration_repository.py
  • from dataclasses import dataclass, field
    from datetime import datetime
    from typing import Optional
    from uuid import UUID
    
    
    @dataclass
    class TenantDriveIntegrationEntity:
        tenant_id: int
        provider: str = "google_drive"
        id: Optional[int] = None
        uid: Optional[UUID] = None
        google_account_email: Optional[str] = None
        refresh_token_encrypted: Optional[str] = None
        access_token_cache: Optional[str] = None
        access_token_expires_at: Optional[datetime] = None
        root_folder_id: Optional[str] = None
        drive_change_cursor: Optional[str] = None
        webhook_channel_id: Optional[str] = None
        webhook_resource_id: Optional[str] = None
        webhook_token: Optional[str] = None
        webhook_expires_at: Optional[datetime] = None
        status: str = "DISCONNECTED"
        connected_by_user_id: Optional[int] = None
        connected_at: Optional[datetime] = None
        last_sync_at: Optional[datetime] = None
        last_sync_error: Optional[str] = None
        created_user: Optional[str] = None
        updated_user: Optional[str] = None
        created_at: Optional[datetime] = None
        updated_at: Optional[datetime] = None
  • from abc import ABC, abstractmethod
    from typing import Optional
    from uuid import UUID
    
    from domain.cloud_integration.entities.tenant_drive_integration_entity import (
        TenantDriveIntegrationEntity,
    )
    
    
    class ITenantDriveIntegrationRepo(ABC):
        @abstractmethod
        def get_by_tenant_id(self, tenant_id: int) -> Optional[TenantDriveIntegrationEntity]: ...
    
        @abstractmethod
        def get_by_uid(self, uid: UUID) -> Optional[TenantDriveIntegrationEntity]: ...
    
        @abstractmethod
        def add(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity: ...
    
        @abstractmethod
        def update(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity: ...
    
        @abstractmethod
        def delete_by_tenant_id(self, tenant_id: int) -> bool: ...
  • git add domain/cloud_integration/
    git commit -m "feat(cloud_integration): add TenantDriveIntegration entity & repo interface"

Task 6: Mapper

Files:

  • Create: infra/cloud_integration/mapper/__init__.py
  • Create: infra/cloud_integration/mapper/tenant_drive_integration_mapper.py
  • from infra.cloud_integration.models.tenant_drive_integration import TenantDriveIntegration
    from domain.cloud_integration.entities.tenant_drive_integration_entity import (
        TenantDriveIntegrationEntity,
    )
    
    
    class TenantDriveIntegrationMapper:
        @staticmethod
        def to_entity(model: TenantDriveIntegration) -> TenantDriveIntegrationEntity:
            return TenantDriveIntegrationEntity(
                id=model.id,
                uid=model.uid,
                tenant_id=model.tenant_id,
                provider=model.provider,
                google_account_email=model.google_account_email,
                refresh_token_encrypted=model.refresh_token_encrypted,
                access_token_cache=model.access_token_cache,
                access_token_expires_at=model.access_token_expires_at,
                root_folder_id=model.root_folder_id,
                drive_change_cursor=model.drive_change_cursor,
                webhook_channel_id=model.webhook_channel_id,
                webhook_resource_id=model.webhook_resource_id,
                webhook_token=model.webhook_token,
                webhook_expires_at=model.webhook_expires_at,
                status=model.status,
                connected_by_user_id=model.connected_by_user_id,
                connected_at=model.connected_at,
                last_sync_at=model.last_sync_at,
                last_sync_error=model.last_sync_error,
                created_user=model.created_user,
                updated_user=model.updated_user,
                created_at=model.created_at,
                updated_at=model.updated_at,
            )
    
        @staticmethod
        def apply_to_model(entity: TenantDriveIntegrationEntity, model: TenantDriveIntegration) -> None:
            """Update model fields from entity (id/uid/created_at protected)."""
            model.tenant_id = entity.tenant_id
            model.provider = entity.provider
            model.google_account_email = entity.google_account_email
            model.refresh_token_encrypted = entity.refresh_token_encrypted
            model.access_token_cache = entity.access_token_cache
            model.access_token_expires_at = entity.access_token_expires_at
            model.root_folder_id = entity.root_folder_id
            model.drive_change_cursor = entity.drive_change_cursor
            model.webhook_channel_id = entity.webhook_channel_id
            model.webhook_resource_id = entity.webhook_resource_id
            model.webhook_token = entity.webhook_token
            model.webhook_expires_at = entity.webhook_expires_at
            model.status = entity.status
            model.connected_by_user_id = entity.connected_by_user_id
            model.connected_at = entity.connected_at
            model.last_sync_at = entity.last_sync_at
            model.last_sync_error = entity.last_sync_error
            model.updated_user = entity.updated_user
  • git add infra/cloud_integration/mapper/
    git commit -m "feat(cloud_integration): add TenantDriveIntegration mapper"

Task 7: Repository Implementation

Files:

  • Create: infra/cloud_integration/repository/__init__.py
  • Create: infra/cloud_integration/repository/tenant_drive_integration_repo_impl.py
  • Create: test/cloud_integration/test_tenant_drive_integration_repo.py
  • import pytest
    from uuid import uuid4
    from infra.cloud_integration.repository.tenant_drive_integration_repo_impl import (
        TenantDriveIntegrationRepoImpl,
    )
    from domain.cloud_integration.entities.tenant_drive_integration_entity import (
        TenantDriveIntegrationEntity,
    )
    
    @pytest.mark.usefixtures("db_session")
    def test_add_and_get_by_tenant_id(db_session):
        repo = TenantDriveIntegrationRepoImpl(session=db_session)
        entity = TenantDriveIntegrationEntity(
            tenant_id=99001,  # 使用測試用大 ID 避免衝突
            status="DISCONNECTED",
            created_user="test",
            updated_user="test",
        )
        saved = repo.add(entity)
        assert saved.id is not None
        assert saved.uid is not None
    
        fetched = repo.get_by_tenant_id(99001)
        assert fetched is not None
        assert fetched.tenant_id == 99001
    
    @pytest.mark.usefixtures("db_session")
    def test_update_status(db_session):
        repo = TenantDriveIntegrationRepoImpl(session=db_session)
        entity = TenantDriveIntegrationEntity(tenant_id=99002, created_user="t", updated_user="t")
        saved = repo.add(entity)
    
        saved.status = "CONNECTED"
        saved.google_account_email = "x@y.com"
        updated = repo.update(saved)
        assert updated.status == "CONNECTED"
        assert updated.google_account_email == "x@y.com"
    
    @pytest.mark.usefixtures("db_session")
    def test_delete_by_tenant_id(db_session):
        repo = TenantDriveIntegrationRepoImpl(session=db_session)
        entity = TenantDriveIntegrationEntity(tenant_id=99003, created_user="t", updated_user="t")
        repo.add(entity)
        assert repo.delete_by_tenant_id(99003) is True
        assert repo.get_by_tenant_id(99003) is None
  • from typing import Optional
    from uuid import UUID
    
    from infra.cloud_integration.mapper.tenant_drive_integration_mapper import (
        TenantDriveIntegrationMapper,
    )
    from infra.cloud_integration.models.tenant_drive_integration import TenantDriveIntegration
    from domain.cloud_integration.entities.tenant_drive_integration_entity import (
        TenantDriveIntegrationEntity,
    )
    from domain.cloud_integration.repository.tenant_drive_integration_repository import (
        ITenantDriveIntegrationRepo,
    )
    
    
    class TenantDriveIntegrationRepoImpl(ITenantDriveIntegrationRepo):
        def __init__(self, session):
            self.session = session
    
        def get_by_tenant_id(self, tenant_id: int) -> Optional[TenantDriveIntegrationEntity]:
            model = (
                self.session.query(TenantDriveIntegration)
                .filter(TenantDriveIntegration.tenant_id == tenant_id)
                .first()
            )
            return TenantDriveIntegrationMapper.to_entity(model) if model else None
    
        def get_by_uid(self, uid: UUID) -> Optional[TenantDriveIntegrationEntity]:
            model = (
                self.session.query(TenantDriveIntegration)
                .filter(TenantDriveIntegration.uid == uid)
                .first()
            )
            return TenantDriveIntegrationMapper.to_entity(model) if model else None
    
        def add(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity:
            model = TenantDriveIntegration()
            TenantDriveIntegrationMapper.apply_to_model(entity, model)
            model.created_user = entity.created_user
            self.session.add(model)
            self.session.flush()
            return TenantDriveIntegrationMapper.to_entity(model)
    
        def update(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity:
            from jedi_common.handler.exception import NotFound
            from common.code.grc_error_code import GrcErrorCode
    
            model = (
                self.session.query(TenantDriveIntegration)
                .filter(TenantDriveIntegration.id == entity.id)
                .first()
            )
            if not model:
                raise NotFound(GrcErrorCode.GRC_DRIVE_INTEGRATION_NOT_FOUND)
            TenantDriveIntegrationMapper.apply_to_model(entity, model)
            self.session.flush()
            return TenantDriveIntegrationMapper.to_entity(model)
    
        def delete_by_tenant_id(self, tenant_id: int) -> bool:
            deleted = (
                self.session.query(TenantDriveIntegration)
                .filter(TenantDriveIntegration.tenant_id == tenant_id)
                .delete()
            )
            self.session.flush()
            return deleted > 0
  • git add infra/cloud_integration/repository/ test/cloud_integration/
    git commit -m "feat(cloud_integration): add TenantDriveIntegrationRepo impl + tests"

Task 8: Domain Service — TenantDriveIntegrationDomainService

Files:

  • Create: domain/cloud_integration/service/__init__.py
  • Create: domain/cloud_integration/service/tenant_drive_integration_domain_service.py
  • from typing import Optional
    from uuid import UUID
    
    from jedi_common.handler.exception import ConflictError, NotFound
    from common.code.grc_error_code import GrcErrorCode
    
    from domain.cloud_integration.entities.tenant_drive_integration_entity import (
        TenantDriveIntegrationEntity,
    )
    from domain.cloud_integration.repository.tenant_drive_integration_repository import (
        ITenantDriveIntegrationRepo,
    )
    
    
    class TenantDriveIntegrationDomainService:
        def __init__(self, tenant_drive_integration_repo: ITenantDriveIntegrationRepo):
            self._repo = tenant_drive_integration_repo
    
        def get_or_none(self, tenant_id: int) -> Optional[TenantDriveIntegrationEntity]:
            return self._repo.get_by_tenant_id(tenant_id)
    
        def get_required(self, tenant_id: int) -> TenantDriveIntegrationEntity:
            entity = self._repo.get_by_tenant_id(tenant_id)
            if entity is None:
                raise NotFound(GrcErrorCode.GRC_DRIVE_INTEGRATION_NOT_FOUND)
            return entity
    
        def create(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity:
            existing = self._repo.get_by_tenant_id(entity.tenant_id)
            if existing is not None and existing.status == "CONNECTED":
                raise ConflictError(GrcErrorCode.GRC_DRIVE_INTEGRATION_ALREADY_EXISTS)
            if existing is not None:
                # 之前 disconnected,覆寫 update
                entity.id = existing.id
                entity.uid = existing.uid
                return self._repo.update(entity)
            return self._repo.add(entity)
    
        def update(self, entity: TenantDriveIntegrationEntity) -> TenantDriveIntegrationEntity:
            return self._repo.update(entity)
    
        def disconnect(self, tenant_id: int) -> bool:
            entity = self._repo.get_by_tenant_id(tenant_id)
            if entity is None:
                return False
            entity.status = "DISCONNECTED"
            entity.refresh_token_encrypted = None
            entity.access_token_cache = None
            entity.access_token_expires_at = None
            entity.webhook_channel_id = None
            entity.webhook_resource_id = None
            entity.webhook_token = None
            entity.webhook_expires_at = None
            self._repo.update(entity)
            return True
  • from unittest.mock import MagicMock
    import pytest
    from jedi_common.handler.exception import NotFound, ConflictError
    
    from domain.cloud_integration.entities.tenant_drive_integration_entity import (
        TenantDriveIntegrationEntity,
    )
    from domain.cloud_integration.service.tenant_drive_integration_domain_service import (
        TenantDriveIntegrationDomainService,
    )
    
    
    def test_get_required_raises_not_found_when_missing():
        repo = MagicMock()
        repo.get_by_tenant_id.return_value = None
        svc = TenantDriveIntegrationDomainService(repo)
        with pytest.raises(NotFound):
            svc.get_required(1)
    
    
    def test_create_raises_conflict_when_already_connected():
        repo = MagicMock()
        repo.get_by_tenant_id.return_value = TenantDriveIntegrationEntity(tenant_id=1, status="CONNECTED")
        svc = TenantDriveIntegrationDomainService(repo)
        with pytest.raises(ConflictError):
            svc.create(TenantDriveIntegrationEntity(tenant_id=1))
    
    
    def test_create_overwrites_disconnected_record():
        existing = TenantDriveIntegrationEntity(tenant_id=1, status="DISCONNECTED", id=10)
        repo = MagicMock()
        repo.get_by_tenant_id.return_value = existing
        svc = TenantDriveIntegrationDomainService(repo)
        new_entity = TenantDriveIntegrationEntity(tenant_id=1, status="CONNECTED")
        svc.create(new_entity)
        repo.update.assert_called_once()
        assert new_entity.id == 10  # 覆寫 id
    
    
    def test_create_can_reconnect_after_revoked():
        """Re-connect 後 status 從 REVOKED → CONNECTED 必須允許覆寫"""
        existing = TenantDriveIntegrationEntity(tenant_id=1, status="REVOKED", id=20)
        repo = MagicMock()
        repo.get_by_tenant_id.return_value = existing
        svc = TenantDriveIntegrationDomainService(repo)
        new_entity = TenantDriveIntegrationEntity(tenant_id=1, status="CONNECTED")
        svc.create(new_entity)
        repo.update.assert_called_once()
        assert new_entity.id == 20

    注意 domain service create() 邏輯需把「DISCONNECTED / REVOKED / EXPIRED」三者都視為「可覆寫」,只有 CONNECTED 才拋 ConflictError。請對照 if existing is not None and existing.status == "CONNECTED" 確保 status 比對是對 CONNECTED,其他都走 update path。

  • git add domain/cloud_integration/service/ test/cloud_integration/test_tenant_drive_integration_domain_service.py
    git commit -m "feat(cloud_integration): add TenantDriveIntegrationDomainService + tests"

Task 9: Token Crypto — Interface & Fernet 實作

Files:

  • Create: domain/cloud_integration/service/token_crypto_service.py (interface)
  • Create: infra/cloud_integration/crypto/__init__.py
  • Create: infra/cloud_integration/crypto/fernet_crypto.py
  • Create: test/cloud_integration/test_fernet_crypto.py
  • # domain/cloud_integration/service/token_crypto_service.py
    from abc import ABC, abstractmethod
    
    
    class TokenCryptoService(ABC):
        @abstractmethod
        def encrypt(self, plaintext: str) -> str: ...
    
        @abstractmethod
        def decrypt(self, ciphertext: str) -> str: ...
  • # test/cloud_integration/test_fernet_crypto.py
    from cryptography.fernet import Fernet
    from infra.cloud_integration.crypto.fernet_crypto import FernetCrypto
    
    
    def test_encrypt_decrypt_roundtrip():
        key = Fernet.generate_key().decode()
        crypto = FernetCrypto(key=key)
        plaintext = "my-very-secret-refresh-token"
        ciphertext = crypto.encrypt(plaintext)
        assert ciphertext != plaintext
        assert crypto.decrypt(ciphertext) == plaintext
    
    
    def test_different_keys_cannot_decrypt():
        key1 = Fernet.generate_key().decode()
        key2 = Fernet.generate_key().decode()
        crypto1 = FernetCrypto(key=key1)
        crypto2 = FernetCrypto(key=key2)
        ciphertext = crypto1.encrypt("hello")
        try:
            crypto2.decrypt(ciphertext)
            assert False, "expected exception"
        except Exception:
            pass
    
    
    def test_decrypt_returns_str_not_bytes():
        key = Fernet.generate_key().decode()
        crypto = FernetCrypto(key=key)
        result = crypto.decrypt(crypto.encrypt("foo"))
        assert isinstance(result, str)
  • # infra/cloud_integration/crypto/fernet_crypto.py
    from cryptography.fernet import Fernet
    from domain.cloud_integration.service.token_crypto_service import TokenCryptoService
    
    
    class FernetCrypto(TokenCryptoService):
        def __init__(self, key: str):
            if not key:
                raise ValueError("Fernet key is required (set DRIVE_TOKEN_ENCRYPTION_KEY env)")
            self._fernet = Fernet(key.encode() if isinstance(key, str) else key)
    
        def encrypt(self, plaintext: str) -> str:
            return self._fernet.encrypt(plaintext.encode()).decode()
    
        def decrypt(self, ciphertext: str) -> str:
            return self._fernet.decrypt(ciphertext.encode()).decode()
  • git add domain/cloud_integration/service/token_crypto_service.py infra/cloud_integration/crypto/ test/cloud_integration/test_fernet_crypto.py
    git commit -m "feat(cloud_integration): add TokenCryptoService interface + FernetCrypto impl"

Task 10: Google OAuth Client(Infra 層)

Files:

  • Create: infra/cloud_integration/google_drive/__init__.py
  • Create: infra/cloud_integration/google_drive/google_oauth_client.py
  • Create: test/cloud_integration/test_google_oauth_client.py
  • from unittest.mock import patch, MagicMock
    from infra.cloud_integration.google_drive.google_oauth_client import GoogleOAuthClient
    
    
    def test_build_auth_url_contains_required_params():
        client = GoogleOAuthClient(
            client_id="my-client-id",
            client_secret="my-secret",
            redirect_uri="https://app/callback",
        )
        url = client.build_auth_url(state="csrf123", scopes=["scope1"])
        assert "client_id=my-client-id" in url
        assert "state=csrf123" in url
        assert "access_type=offline" in url
        assert "prompt=consent" in url
        assert "scope=scope1" in url
    
    
    @patch("infra.cloud_integration.google_drive.google_oauth_client.requests.post")
    def test_exchange_code_returns_tokens(mock_post):
        mock_post.return_value.ok = True
        mock_post.return_value.json.return_value = {
            "access_token": "at-xxx",
            "refresh_token": "rt-yyy",
            "expires_in": 3600,
            "token_type": "Bearer",
        }
        client = GoogleOAuthClient("cid", "csec", "https://app/callback")
        result = client.exchange_code("the-code")
        assert result["access_token"] == "at-xxx"
        assert result["refresh_token"] == "rt-yyy"
    
    
    @patch("infra.cloud_integration.google_drive.google_oauth_client.requests.get")
    def test_get_userinfo_returns_email(mock_get):
        mock_get.return_value.ok = True
        mock_get.return_value.json.return_value = {"email": "x@y.com", "verified_email": True}
        client = GoogleOAuthClient("cid", "csec", "https://app/callback")
        info = client.get_userinfo("access-token")
        assert info["email"] == "x@y.com"
  • # infra/cloud_integration/google_drive/google_oauth_client.py
    from typing import Iterable
    from urllib.parse import urlencode
    
    import requests
    
    
    GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
    GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
    GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
    GOOGLE_REVOKE_URL = "https://oauth2.googleapis.com/revoke"
    
    
    class GoogleOAuthClient:
        def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
            self.client_id = client_id
            self.client_secret = client_secret
            self.redirect_uri = redirect_uri
    
        def build_auth_url(self, state: str, scopes: Iterable[str]) -> str:
            params = {
                "client_id": self.client_id,
                "redirect_uri": self.redirect_uri,
                "response_type": "code",
                "scope": " ".join(scopes),
                "access_type": "offline",
                "prompt": "consent",
                "include_granted_scopes": "true",
                "state": state,
            }
            return f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
    
        def exchange_code(self, code: str) -> dict:
            resp = requests.post(
                GOOGLE_TOKEN_URL,
                data={
                    "code": code,
                    "client_id": self.client_id,
                    "client_secret": self.client_secret,
                    "redirect_uri": self.redirect_uri,
                    "grant_type": "authorization_code",
                },
                timeout=10,
            )
            if not resp.ok:
                raise RuntimeError(f"OAuth exchange failed: {resp.status_code} {resp.text}")
            return resp.json()
    
        def refresh_access_token(self, refresh_token: str) -> dict:
            resp = requests.post(
                GOOGLE_TOKEN_URL,
                data={
                    "refresh_token": refresh_token,
                    "client_id": self.client_id,
                    "client_secret": self.client_secret,
                    "grant_type": "refresh_token",
                },
                timeout=10,
            )
            if not resp.ok:
                raise RuntimeError(f"Token refresh failed: {resp.status_code} {resp.text}")
            return resp.json()
    
        def get_userinfo(self, access_token: str) -> dict:
            resp = requests.get(
                GOOGLE_USERINFO_URL,
                headers={"Authorization": f"Bearer {access_token}"},
                timeout=10,
            )
            if not resp.ok:
                raise RuntimeError(f"userinfo failed: {resp.status_code} {resp.text}")
            return resp.json()
    
        def revoke(self, token: str) -> bool:
            resp = requests.post(
                GOOGLE_REVOKE_URL,
                params={"token": token},
                headers={"Content-Type": "application/x-www-form-urlencoded"},
                timeout=10,
            )
            return resp.ok
  • git add infra/cloud_integration/google_drive/ test/cloud_integration/test_google_oauth_client.py
    git commit -m "feat(cloud_integration): add GoogleOAuthClient (auth-url / exchange / refresh / userinfo / revoke)"

Task 11: App Service — GoogleDriveIntegrationService

Files:

  • Create: app/cloud_integration/__init__.py
  • Create: app/cloud_integration/dto/__init__.py
  • Create: app/cloud_integration/dto/google_drive_integration_dto.py
  • Create: app/cloud_integration/service/__init__.py
  • Create: app/cloud_integration/service/google_drive_integration_service.py
  • Create: test/cloud_integration/test_google_drive_integration_service.py
  • # app/cloud_integration/dto/google_drive_integration_dto.py
    from dataclasses import dataclass
    from datetime import datetime
    from typing import Optional
    from uuid import UUID
    
    from domain.cloud_integration.entities.tenant_drive_integration_entity import (
        TenantDriveIntegrationEntity,
    )
    
    
    @dataclass
    class GoogleDriveIntegrationStatusDTO:
        uid: Optional[UUID]
        status: str  # CONNECTED / EXPIRED / REVOKED / DISCONNECTED
        google_account_email: Optional[str]
        root_folder_id: Optional[str]
        connected_by_user_id: Optional[int]
        connected_by_user_name: Optional[str]
        connected_at: Optional[datetime]
        last_sync_at: Optional[datetime]
        webhook_expires_at: Optional[datetime]
        last_sync_error: Optional[str]
    
        @classmethod
        def from_entity(
            cls,
            entity: Optional[TenantDriveIntegrationEntity],
            connected_by_user_name: Optional[str] = None,
        ) -> "GoogleDriveIntegrationStatusDTO":
            if entity is None:
                return cls(
                    uid=None,
                    status="DISCONNECTED",
                    google_account_email=None,
                    root_folder_id=None,
                    connected_by_user_id=None,
                    connected_by_user_name=None,
                    connected_at=None,
                    last_sync_at=None,
                    webhook_expires_at=None,
                    last_sync_error=None,
                )
            return cls(
                uid=entity.uid,
                status=entity.status,
                google_account_email=entity.google_account_email,
                root_folder_id=entity.root_folder_id,
                connected_by_user_id=entity.connected_by_user_id,
                connected_by_user_name=connected_by_user_name,
                connected_at=entity.connected_at,
                last_sync_at=entity.last_sync_at,
                webhook_expires_at=entity.webhook_expires_at,
                last_sync_error=entity.last_sync_error,
            )
    • build_auth_url(tenant_id, user_id) -> dict{auth_url, state} — 產 state token 存 Redis (TTL 10min)
    • handle_callback(code, state) -> entity — 驗 state、call OAuth client 換 token、加密存 DB
    • get_status(tenant_id) -> DTO — 查現況
    • disconnect(tenant_id) -> bool — 撤銷 + 清 token
    # app/cloud_integration/service/google_drive_integration_service.py
    import secrets
    from datetime import datetime, timedelta, timezone
    from typing import Optional
    
    from flask import current_app
    from jedi_common.handler.exception import BadRequestError
    from jedi_common.session.database.db import transaction
    from jedi_auth.domain.service.user_domain_service import UserDomainService
    
    from common.code.grc_error_code import GrcErrorCode
    from core.extensions import redis_client
    
    from app.cloud_integration.dto.google_drive_integration_dto import (
        GoogleDriveIntegrationStatusDTO,
    )
    from domain.cloud_integration.entities.tenant_drive_integration_entity import (
        TenantDriveIntegrationEntity,
    )
    from domain.cloud_integration.service.tenant_drive_integration_domain_service import (
        TenantDriveIntegrationDomainService,
    )
    from domain.cloud_integration.service.token_crypto_service import TokenCryptoService
    from infra.cloud_integration.google_drive.google_oauth_client import GoogleOAuthClient
    
    
    STATE_REDIS_PREFIX = "drive_oauth_state:"
    STATE_TTL_SECONDS = 600  # 10 min
    REQUIRED_SCOPES = [
        "https://www.googleapis.com/auth/drive",
        "https://www.googleapis.com/auth/userinfo.email",
    ]
    
    
    class GoogleDriveIntegrationService:
        def __init__(
            self,
            tenant_drive_integration_domain_service: TenantDriveIntegrationDomainService,
            token_crypto_service: TokenCryptoService,
            google_oauth_client: GoogleOAuthClient,
            user_domain_service: UserDomainService,
        ):
            self._domain = tenant_drive_integration_domain_service
            self._crypto = token_crypto_service
            self._oauth = google_oauth_client
            self._user_domain = user_domain_service
    
        # ─────────────────────────────────────────────────
        def build_auth_url(self, tenant_id: int, user_id: int) -> dict:
            state = secrets.token_urlsafe(32)
            payload = f"{tenant_id}:{user_id}"
            redis_client.setex(STATE_REDIS_PREFIX + state, STATE_TTL_SECONDS, payload)
            auth_url = self._oauth.build_auth_url(state=state, scopes=REQUIRED_SCOPES)
            return {"auth_url": auth_url, "state": state}
    
        # ─────────────────────────────────────────────────
        @transaction
        def handle_callback(self, code: str, state: str) -> TenantDriveIntegrationEntity:
            payload = redis_client.get(STATE_REDIS_PREFIX + state)
            if payload is None:
                raise BadRequestError(GrcErrorCode.GRC_DRIVE_OAUTH_STATE_INVALID)
            redis_client.delete(STATE_REDIS_PREFIX + state)
    
            tenant_id_str, user_id_str = payload.decode().split(":")
            tenant_id = int(tenant_id_str)
            user_id = int(user_id_str)
    
            tokens = self._oauth.exchange_code(code)
            refresh_token = tokens.get("refresh_token")
            access_token = tokens["access_token"]
            expires_in = tokens.get("expires_in", 3600)
    
            if not refresh_token:
                # 常見原因:user 之前已對該 OAuth client 同意過授權,
                # Google 不會再次回傳 refresh_token;要求 user 至 myaccount.google.com/permissions 移除舊授權再試。
                raise BadRequestError(GrcErrorCode.GRC_DRIVE_OAUTH_NO_REFRESH_TOKEN)
    
            userinfo = self._oauth.get_userinfo(access_token)
            user = self._user_domain.get_by_id(user_id)
            login_name = user.login_name if user else "system"
    
            entity = TenantDriveIntegrationEntity(
                tenant_id=tenant_id,
                provider="google_drive",
                google_account_email=userinfo.get("email"),
                refresh_token_encrypted=self._crypto.encrypt(refresh_token),
                access_token_cache=self._crypto.encrypt(access_token),
                access_token_expires_at=datetime.now(timezone.utc) + timedelta(seconds=expires_in - 60),
                status="CONNECTED",
                connected_by_user_id=user_id,
                connected_at=datetime.now(timezone.utc),
                created_user=login_name,
                updated_user=login_name,
            )
            return self._domain.create(entity)
    
        # ─────────────────────────────────────────────────
        def get_status(self, tenant_id: int) -> GoogleDriveIntegrationStatusDTO:
            entity = self._domain.get_or_none(tenant_id)
            connected_by_name = None
            if entity and entity.connected_by_user_id:
                user = self._user_domain.get_by_id(entity.connected_by_user_id)
                if user:
                    connected_by_name = user.nickname
            return GoogleDriveIntegrationStatusDTO.from_entity(entity, connected_by_name)
    
        # ─────────────────────────────────────────────────
        def disconnect(self, tenant_id: int) -> bool:
            """Disconnect = (1) 先嘗試 best-effort revoke OAuth token (HTTP, 不在 transaction 內)
                           (2) 再 commit 把 token 從 DB 清除。
            revoke 失敗不影響 (2)。"""
            # Step 1: revoke (no DB transaction — HTTP call only)
            entity = self._domain.get_or_none(tenant_id)
            if entity is None:
                return False
            if entity.refresh_token_encrypted:
                try:
                    rt = self._crypto.decrypt(entity.refresh_token_encrypted)
                    self._oauth.revoke(rt)
                except Exception:
                    pass  # ignore revoke errors — DB 仍會清
    
            # Step 2: clear DB (own transaction)
            return self._clear_db(tenant_id)
    
        @transaction
        def _clear_db(self, tenant_id: int) -> bool:
            return self._domain.disconnect(tenant_id)

    重要:OAuth revoke 是 HTTP call 給 Google,可能 timeout (~10s)。如果包在 @transaction 內會把 DB session/連線卡住整個 timeout 時間。所以拆成兩段:先做 HTTP 不開 transaction,再單獨開 transaction 清 DB。

    • build_auth_url 寫 redis、回 url
    • handle_callback state 不存在 → BadRequest
    • handle_callback 成功 → 寫 entity
    • get_status 帶 nickname
    • disconnect 撤銷 + 標 status

    (測試完整 listing 略,依 Task 8 同 pattern;確保用 MagicMock 注入 4 個依賴)

  • git add app/cloud_integration/ test/cloud_integration/test_google_drive_integration_service.py
    git commit -m "feat(cloud_integration): add GoogleDriveIntegrationService (auth-url/callback/status/disconnect)"

Task 12: Marshmallow Serializers

Files:

  • Create: api/cloud_integration/serializers/__init__.py
  • Create: api/cloud_integration/serializers/google_drive_integration.py
  • # api/cloud_integration/serializers/google_drive_integration.py
    from marshmallow import Schema, fields
    
    
    class GoogleDriveAuthUrlResponse(Schema):
        auth_url = fields.String(required=True)
        state = fields.String(required=True)
    
    
    class GoogleDriveStatusResponse(Schema):
        uid = fields.UUID(allow_none=True)
        status = fields.String(required=True)
        google_account_email = fields.String(allow_none=True)
        root_folder_id = fields.String(allow_none=True)
        connected_by_user_id = fields.Integer(allow_none=True)
        connected_by_user_name = fields.String(allow_none=True)
        connected_at = fields.DateTime(allow_none=True)
        last_sync_at = fields.DateTime(allow_none=True)
        webhook_expires_at = fields.DateTime(allow_none=True)
        last_sync_error = fields.String(allow_none=True)
    
    
    class GoogleDriveCallbackQuerySchema(Schema):
        code = fields.String(required=True)
        state = fields.String(required=True)
  • git add api/cloud_integration/serializers/
    git commit -m "feat(cloud_integration): add Drive integration serializers"

Task 13: API Routes

Files:

  • Create: api/cloud_integration/routes/__init__.py
  • Create: api/cloud_integration/routes/google_drive_integration_route.py
  • from jedi_common.handler.exception import ForbiddenError
    from common.code.grc_error_code import GrcErrorCode
    
    def build_auth_url(self, tenant_id: int, user_id: int, *, is_admin: bool) -> dict:
        if not is_admin:
            raise ForbiddenError(GrcErrorCode.GRC_NOT_ADMIN)
        ... (原邏輯)
    
    def disconnect(self, tenant_id: int, *, is_admin: bool) -> bool:
        if not is_admin:
            raise ForbiddenError(GrcErrorCode.GRC_NOT_ADMIN)
        ... (原邏輯)

    同時更新 Task 11 的單元測試補:「非 admin 呼叫 build_auth_url / disconnect 拋 ForbiddenError + GRC_NOT_ADMIN」。

  • # api/cloud_integration/routes/google_drive_integration_route.py
    from dataclasses import asdict
    
    from dependency_injector.wiring import inject, Provide
    from flask import request, Response, current_app
    from flask_apispec import MethodResource, doc, marshal_with
    from flask_jwt_extended import jwt_required
    from jedi_common.session.auth.auth_context import get_user_context
    
    from api.cloud_integration.serializers.google_drive_integration import (
        GoogleDriveAuthUrlResponse,
        GoogleDriveStatusResponse,
    )
    from app.cloud_integration.service.google_drive_integration_service import (
        GoogleDriveIntegrationService,
    )
    from common.util.response_util import return_response
    from di_containers.containers import Containers
    
    
    class GoogleDriveIntegrationRoute(MethodResource):
        """`/google-drive` — 同時負責 GET (status, 任何登入者) 與 DELETE (disconnect, admin only)."""
    
        @doc(description="取得當前 tenant 的 Google Drive 連線狀態", tags=["Cloud Integration"])
        @marshal_with(GoogleDriveStatusResponse, apply=False)
        @jwt_required()
        @inject
        def get(
            self,
            service: GoogleDriveIntegrationService = Provide[
                Containers.cloud_integration_container.google_drive_integration_service
            ],
        ):
            user = get_user_context()
            dto = service.get_status(user.tenant_id)
            return return_response(True, GoogleDriveStatusResponse().dump(asdict(dto)))
    
        @doc(description="中斷 Google Drive 連線", tags=["Cloud Integration"])
        @jwt_required()
        @inject
        def delete(
            self,
            service: GoogleDriveIntegrationService = Provide[
                Containers.cloud_integration_container.google_drive_integration_service
            ],
        ):
            user = get_user_context()
            ok = service.disconnect(user.tenant_id, is_admin=user.is_admin)
            return return_response(True, {"disconnected": ok})
    
    
    class GoogleDriveAuthUrlRoute(MethodResource):
    
        @doc(description="產生 OAuth 授權 URL", tags=["Cloud Integration"])
        @marshal_with(GoogleDriveAuthUrlResponse, apply=False)
        @jwt_required()
        @inject
        def post(
            self,
            service: GoogleDriveIntegrationService = Provide[
                Containers.cloud_integration_container.google_drive_integration_service
            ],
        ):
            user = get_user_context()
            result = service.build_auth_url(user.tenant_id, user.user_id, is_admin=user.is_admin)
            return return_response(True, result)
    
    
    class GoogleDriveCallbackRoute(MethodResource):
    
        @doc(description="OAuth callback (Google redirect 進來)", tags=["Cloud Integration"])
        @inject
        def get(
            self,
            service: GoogleDriveIntegrationService = Provide[
                Containers.cloud_integration_container.google_drive_integration_service
            ],
        ):
            code = request.args.get("code")
            state = request.args.get("state")
            error = request.args.get("error")
    
            if error or not code or not state:
                return Response(_callback_html("error", error or "missing_params"), mimetype="text/html")
    
            try:
                service.handle_callback(code, state)
            except Exception as e:
                return Response(_callback_html("error", str(e)), mimetype="text/html")
    
            return Response(_callback_html("success", None), mimetype="text/html")
    
    
    def _callback_html(status: str, error: str | None) -> str:
        """回 HTML,用 postMessage 通知 opener 後 self.close().
        targetOrigin 從 app config 取,避免用 '*' 造成 token leak 風險。"""
        err_repr = f'"{error}"' if error else "null"
        target_origin = current_app.config.get("FRONTEND_BASE_URL", "*")
        return f"""
    <!doctype html>
    <html>
    <body>
    <p>Google Drive 連線完成({status}),視窗即將關閉...</p>
    <script>
      try {{
        if (window.opener) {{
          window.opener.postMessage(
            {{ source: 'guidant-drive-oauth', status: '{status}', error: {err_repr} }},
            '{target_origin}'
          );
        }}
      }} catch (e) {{ console.error(e); }}
      setTimeout(function() {{ window.close(); }}, 800);
    </script>
    </body>
    </html>
    """

    並在 config/config.py 新增 FRONTEND_BASE_URL = os.getenv("FRONTEND_BASE_URL", "*"),production 必設、dev 留 *

  • def create_module():
        from api.cloud_integration.routes.google_drive_integration_route import (
            GoogleDriveIntegrationRoute,
            GoogleDriveAuthUrlRoute,
            GoogleDriveCallbackRoute,
        )
    
        # 注意:url_prefix 用 /api(不是 /api/integrations),
        # 為了讓 Phase 3 的 webhook receiver (/api/webhooks/google-drive/<tenant_id>)
        # 也能掛在同一個 Blueprint 上。
        bp = Blueprint("cloud_integration", __name__, url_prefix="/api")
        api = Api(bp)
    
        api.add_resource(GoogleDriveIntegrationRoute, "/integrations/google-drive")  # GET + DELETE
        api.add_resource(GoogleDriveAuthUrlRoute, "/integrations/google-drive/auth-url")
        api.add_resource(GoogleDriveCallbackRoute, "/integrations/google-drive/callback")
    
        logger.info("Cloud Integration module registered")
        return bp

    設計注意

    1. GoogleDriveIntegrationRoute 是單一 Resource class 同時實作 get() (status) 與 delete() (disconnect),避免 Flask-RESTful 不允許多 class 同 path 的限制。
    2. Blueprint url_prefix='/api' 是為了 Phase 3 webhook 共用此 Blueprint(不需多 Blueprint return list — main_app.py 只接受單一 Blueprint)。
  • git add api/cloud_integration/routes/ api/cloud_integration/__init__.py
    git commit -m "feat(cloud_integration): add Google Drive integration routes"

Task 14: DI Container

Files:

  • Create: di_containers/cloud_integration/__init__.py
  • Create: di_containers/cloud_integration/cloud_integration_containers.py
  • Modify: di_containers/containers.py
  • # di_containers/cloud_integration/cloud_integration_containers.py
    from dependency_injector import containers, providers
    from jedi_common.session.database.db import get_session
    
    from infra.cloud_integration.repository.tenant_drive_integration_repo_impl import (
        TenantDriveIntegrationRepoImpl,
    )
    from infra.cloud_integration.crypto.fernet_crypto import FernetCrypto
    from infra.cloud_integration.google_drive.google_oauth_client import GoogleOAuthClient
    
    from domain.cloud_integration.service.tenant_drive_integration_domain_service import (
        TenantDriveIntegrationDomainService,
    )
    
    from app.cloud_integration.service.google_drive_integration_service import (
        GoogleDriveIntegrationService,
    )
    
    
    class CloudIntegrationContainer(containers.DeclarativeContainer):
        config = providers.Configuration()
        auth_container = providers.DependenciesContainer()
    
        # repos
        tenant_drive_integration_repo = providers.Factory(
            TenantDriveIntegrationRepoImpl,
            session=providers.Resource(get_session),
        )
    
        # domain services
        tenant_drive_integration_domain_service = providers.Factory(
            TenantDriveIntegrationDomainService,
            tenant_drive_integration_repo=tenant_drive_integration_repo,
        )
    
        # crypto
        # (app.config 已透過 core/app_factory.py 的 container.config.from_dict(app.config) 注入,
        # 所以這裡直接用大寫 key — 與 config/config.py 屬性名一致)
        token_crypto_service = providers.Singleton(
            FernetCrypto,
            key=config.DRIVE_TOKEN_ENCRYPTION_KEY,
        )
    
        # oauth client
        google_oauth_client = providers.Singleton(
            GoogleOAuthClient,
            client_id=config.GOOGLE_DRIVE_OAUTH_CLIENT_ID,
            client_secret=config.GOOGLE_DRIVE_OAUTH_CLIENT_SECRET,
            redirect_uri=config.GOOGLE_DRIVE_OAUTH_REDIRECT_URI,
        )
    
        # app service
        google_drive_integration_service = providers.Factory(
            GoogleDriveIntegrationService,
            tenant_drive_integration_domain_service=tenant_drive_integration_domain_service,
            token_crypto_service=token_crypto_service,
            google_oauth_client=google_oauth_client,
            user_domain_service=auth_container.user_domain_service,
        )
  • from di_containers.cloud_integration.cloud_integration_containers import CloudIntegrationContainer
    # ...
    cloud_integration_container: CloudIntegrationContainer = providers.Container(
        CloudIntegrationContainer,
        config=config,                  # 全 app.config(已自動 from_dict 注入),不限 sub-key
        auth_container=auth_container,
    )
  • # 在 cloud_integration_containers.py 內參考 config 時:
    token_crypto_service = providers.Singleton(
        FernetCrypto,
        key=config.DRIVE_TOKEN_ENCRYPTION_KEY,
    )
    google_oauth_client = providers.Singleton(
        GoogleOAuthClient,
        client_id=config.GOOGLE_DRIVE_OAUTH_CLIENT_ID,
        client_secret=config.GOOGLE_DRIVE_OAUTH_CLIENT_SECRET,
        redirect_uri=config.GOOGLE_DRIVE_OAUTH_REDIRECT_URI,
    )

    改 Step 1 的 container 定義對應更新(key 名從 config.token_encryption_key 改成 config.DRIVE_TOKEN_ENCRYPTION_KEY,依此類推)。 用 grep -rn "config\." di_containers/ 看其他既有 container 怎麼存取 app config 為參考(例如 di_containers/system_config/di_containers/notification/)。

  • git add di_containers/cloud_integration/ di_containers/containers.py main_app.py
    git commit -m "feat(cloud_integration): wire DI container + register in main"

Task 15: 後端 E2E 測試(route + service + DB)

Files:

  • Create: test/cloud_integration/test_google_drive_integration_route.py
  • import json
    from unittest.mock import patch
    import pytest
    
    
    @pytest.mark.usefixtures("db_session")
    def test_get_status_disconnected_initially(client, headers):
        resp = client.get("/api/integrations/google-drive", headers=headers)
        assert resp.status_code == 200
        body = resp.get_json()
        assert body["data"]["status"] == "DISCONNECTED"
    
    
    @pytest.mark.usefixtures("db_session")
    def test_post_auth_url_returns_url_for_admin(admin_client, admin_headers):
        resp = admin_client.post("/api/integrations/google-drive/auth-url", headers=admin_headers)
        assert resp.status_code == 200
        assert "auth_url" in resp.get_json()["data"]
    
    
    @pytest.mark.usefixtures("db_session")
    def test_post_auth_url_forbidden_for_non_admin(client, headers):
        resp = client.post("/api/integrations/google-drive/auth-url", headers=headers)
        assert resp.status_code == 403
    
    
    @pytest.mark.usefixtures("db_session")
    @patch("infra.cloud_integration.google_drive.google_oauth_client.requests.post")
    @patch("infra.cloud_integration.google_drive.google_oauth_client.requests.get")
    def test_callback_creates_integration(mock_get, mock_post, admin_client, admin_headers, redis_client):
        # 1. 先取 auth-url 拿到 state
        resp = admin_client.post("/api/integrations/google-drive/auth-url", headers=admin_headers)
        state = resp.get_json()["data"]["state"]
    
        # 2. mock token exchange + userinfo
        mock_post.return_value.ok = True
        mock_post.return_value.json.return_value = {
            "access_token": "at-x", "refresh_token": "rt-y", "expires_in": 3600,
        }
        mock_get.return_value.ok = True
        mock_get.return_value.json.return_value = {"email": "shared@company.com"}
    
        # 3. 呼叫 callback
        resp = admin_client.get(f"/api/integrations/google-drive/callback?code=the-code&state={state}")
        assert resp.status_code == 200
        assert b"success" in resp.data
    
        # 4. 驗 status 變成 CONNECTED
        resp = admin_client.get("/api/integrations/google-drive", headers=admin_headers)
        assert resp.get_json()["data"]["status"] == "CONNECTED"
        assert resp.get_json()["data"]["google_account_email"] == "shared@company.com"
    
    
    @pytest.mark.usefixtures("db_session")
    def test_disconnect(admin_client, admin_headers):
        # 假設已連線(前一個 test 的延伸或 fixture 預先建好)
        resp = admin_client.delete("/api/integrations/google-drive", headers=admin_headers)
        assert resp.status_code == 200
        resp = admin_client.get("/api/integrations/google-drive", headers=admin_headers)
        assert resp.get_json()["data"]["status"] == "DISCONNECTED"
  • 若沒有 admin_client / admin_headers fixture,先在 test/cloud_integration/conftest.py 補上(參考 tests/conftest.py 既有 pattern)。

  • git add test/cloud_integration/test_google_drive_integration_route.py test/cloud_integration/conftest.py
    git commit -m "test(cloud_integration): add E2E tests for Google Drive integration routes"

Task 16: 前端 — API 常數 & Service

Files:

  • Modify: src/config/api/api.js
  • Create: src/service/CloudIntegrationService.js

切換工作目錄到前端:cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-fe

  • // ── Cloud Integration ─────────────────────────────────────
    INTEGRATION_GDRIVE: '/integrations/google-drive',
    INTEGRATION_GDRIVE_AUTH_URL: '/integrations/google-drive/auth-url',
  • import { BaseService } from '@/service/BaseService'
    import API from '@/config/api/api'
    
    export class CloudIntegrationService extends BaseService {
        async getGoogleDriveStatus() {
            return this.get(API.INTEGRATION_GDRIVE)
        }
    
        async getGoogleDriveAuthUrl() {
            return this.post(API.INTEGRATION_GDRIVE_AUTH_URL)
        }
    
        async disconnectGoogleDrive() {
            return this.delete(API.INTEGRATION_GDRIVE)
        }
    }
  • git add src/config/api/api.js src/service/CloudIntegrationService.js
    git commit -m "feat(cloud-integration): add CloudIntegrationService + API constants"

Task 17: 前端 — i18n 字串

Files:

  • Modify: src/lang/zh-TW.js (or wherever zh strings live)
  • Modify: src/lang/en.js
  • cloud_integration: {
        page_title: '雲端空間整合設定',
        gdrive: {
            title: 'Google Drive',
            description: '透過 Drive 上傳 evidence,自動同步進系統。',
            warning: '⚠ 將授權系統存取連線帳號的整個 Drive,建議使用 dedicated 共用公司帳號。',
            connect_btn: '連線 Google Drive',
            disconnect_btn: '中斷連線',
            reauth_btn: '重新授權',
            sync_now_btn: '立即同步',
            status_connected: '已連線',
            status_disconnected: '未連線',
            status_revoked: '連線已失效',
            status_expired: '授權已過期',
            field_account: '連線帳號',
            field_root_folder: '根目錄',
            field_connected_at: '連線時間',
            field_last_sync: '上次同步',
            field_webhook_expires: 'Webhook 到期',
            connecting: '連線中…',
            connect_success: 'Google Drive 連線成功!',
            connect_error: '連線失敗:{error}',
            disconnect_confirm: '確定要中斷 Google Drive 連線嗎?已快取的證據檔案仍可下載,但 Drive 上新的變更將不再同步。',
            disconnect_success: '已中斷 Google Drive 連線',
        }
    }
  • git add src/lang/
    git commit -m "feat(cloud-integration): add i18n strings for Google Drive integration"

Task 18: 前端 — GoogleDriveIntegrationCard.vue 元件

Files:

  • Create: src/components/integrations/GoogleDriveIntegrationCard.vue
  • <script setup>
    import { ref, onMounted, onUnmounted, computed } from 'vue'
    import { useI18n } from 'vue-i18n'
    import { useToast } from 'primevue/usetoast'
    import { useConfirm } from 'primevue/useconfirm'
    import { CloudIntegrationService } from '@/service/CloudIntegrationService'
    
    const { t, d } = useI18n()
    const toast = useToast()
    const confirm = useConfirm()
    const service = new CloudIntegrationService()
    
    const status = ref(null)   // GoogleDriveStatusResponse
    const loading = ref(false)
    const connecting = ref(false)
    let messageHandler = null
    
    async function refresh() {
        loading.value = true
        try {
            status.value = await service.getGoogleDriveStatus()
        } finally {
            loading.value = false
        }
    }
    
    async function connect() {
        connecting.value = true
        try {
            const { auth_url } = await service.getGoogleDriveAuthUrl()
            const popup = window.open(auth_url, 'gdrive-oauth', 'width=500,height=700')
    
            messageHandler = (event) => {
                const msg = event.data
                if (!msg || msg.source !== 'guidant-drive-oauth') return
                if (msg.status === 'success') {
                    toast.add({ severity: 'success', summary: t('lang.cloud_integration.gdrive.connect_success'), life: 3000 })
                    refresh()
                } else {
                    toast.add({ severity: 'error', summary: t('lang.cloud_integration.gdrive.connect_error', { error: msg.error || '' }), life: 5000 })
                }
                connecting.value = false
                cleanup()
            }
            window.addEventListener('message', messageHandler)
        } catch (e) {
            connecting.value = false
            toast.add({ severity: 'error', summary: e?.message || 'Error', life: 4000 })
        }
    }
    
    function disconnect() {
        confirm.require({
            message: t('lang.cloud_integration.gdrive.disconnect_confirm'),
            header: t('lang.cloud_integration.gdrive.disconnect_btn'),
            accept: async () => {
                await service.disconnectGoogleDrive()
                toast.add({ severity: 'info', summary: t('lang.cloud_integration.gdrive.disconnect_success'), life: 3000 })
                refresh()
            }
        })
    }
    
    function cleanup() {
        if (messageHandler) {
            window.removeEventListener('message', messageHandler)
            messageHandler = null
        }
    }
    
    const isConnected = computed(() => status.value?.status === 'CONNECTED')
    const isError = computed(() => ['REVOKED', 'EXPIRED'].includes(status.value?.status))
    const driveRootUrl = computed(() => {
        if (!status.value?.root_folder_id) return null
        return `https://drive.google.com/drive/folders/${status.value.root_folder_id}`
    })
    
    onMounted(refresh)
    onUnmounted(cleanup)
    </script>
    
    <template>
        <div class="surface-card border-round-xl shadow-2 p-5 flex flex-column gap-4"
             style="border: 1px solid var(--surface-border)">
            <div class="flex align-items-center gap-3">
                <i class="pi pi-google text-3xl" style="color: #4285f4"></i>
                <div>
                    <div class="text-xl font-semibold">{{ t('lang.cloud_integration.gdrive.title') }}</div>
                    <div class="text-sm text-color-secondary">{{ t('lang.cloud_integration.gdrive.description') }}</div>
                </div>
            </div>
    
            <ProgressBar v-if="loading" mode="indeterminate" style="height: 4px" />
    
            <!-- 未連線 -->
            <div v-if="status && status.status === 'DISCONNECTED' && !loading"
                 class="flex flex-column gap-3">
                <div class="text-sm text-color-secondary">
                    {{ t('lang.cloud_integration.gdrive.warning') }}
                </div>
                <Button :label="t('lang.cloud_integration.gdrive.connect_btn')"
                        icon="pi pi-link"
                        :loading="connecting"
                        @click="connect" />
            </div>
    
            <!-- 連線異常 -->
            <Message v-else-if="isError" severity="error" :closable="false">
                {{ status.status === 'REVOKED' ? t('lang.cloud_integration.gdrive.status_revoked')
                                                : t('lang.cloud_integration.gdrive.status_expired') }}
                <Button :label="t('lang.cloud_integration.gdrive.reauth_btn')"
                        severity="warn" size="small" class="ml-3" @click="connect" />
            </Message>
    
            <!-- 已連線 -->
            <div v-else-if="isConnected" class="flex flex-column gap-2">
                <Tag severity="success" :value="t('lang.cloud_integration.gdrive.status_connected')" class="align-self-start" />
                <div class="grid">
                    <div class="col-12 sm:col-6">
                        <div class="text-color-secondary text-xs">{{ t('lang.cloud_integration.gdrive.field_account') }}</div>
                        <div class="font-medium">{{ status.google_account_email }}</div>
                    </div>
                    <div class="col-12 sm:col-6">
                        <div class="text-color-secondary text-xs">{{ t('lang.cloud_integration.gdrive.field_root_folder') }}</div>
                        <div>
                            <a v-if="driveRootUrl" :href="driveRootUrl" target="_blank" rel="noopener"
                               class="text-primary">
                                GuidantAI <i class="pi pi-external-link text-xs ml-1"></i>
                            </a>
                            <span v-else class="text-color-secondary">—</span>
                        </div>
                    </div>
                    <div class="col-12 sm:col-6">
                        <div class="text-color-secondary text-xs">{{ t('lang.cloud_integration.gdrive.field_connected_at') }}</div>
                        <div>{{ status.connected_at ? d(status.connected_at, 'long') : '—' }}
                            <span v-if="status.connected_by_user_name" class="text-color-secondary text-xs">
                                by {{ status.connected_by_user_name }}
                            </span>
                        </div>
                    </div>
                    <div class="col-12 sm:col-6">
                        <div class="text-color-secondary text-xs">{{ t('lang.cloud_integration.gdrive.field_last_sync') }}</div>
                        <div>{{ status.last_sync_at ? d(status.last_sync_at, 'long') : '—' }}</div>
                    </div>
                </div>
    
                <div class="flex gap-2 mt-2">
                    <Button :label="t('lang.cloud_integration.gdrive.disconnect_btn')"
                            icon="pi pi-times" severity="danger" outlined
                            @click="disconnect" />
                    <Button :label="t('lang.cloud_integration.gdrive.reauth_btn')"
                            icon="pi pi-refresh" outlined
                            @click="connect" />
                </div>
            </div>
        </div>
    </template>
  • git add src/components/integrations/
    git commit -m "feat(cloud-integration): add GoogleDriveIntegrationCard component"

Task 19: 前端 — CloudIntegrationsView 主頁

Files:

  • Create: src/views/integrations/CloudIntegrationsView.vue
  • Modify: src/router/index.js
  • Modify: 側邊選單元件 (路徑視專案而定,可能在 AppMenu.vueSidebar.vue)
  • <script setup>
    import { useI18n } from 'vue-i18n'
    import GoogleDriveIntegrationCard from '@/components/integrations/GoogleDriveIntegrationCard.vue'
    
    const { t } = useI18n()
    </script>
    
    <template>
        <div class="p-5 flex flex-column gap-4">
            <h2 class="m-0 font-semibold text-2xl">{{ t('lang.cloud_integration.page_title') }}</h2>
            <div class="grid">
                <div class="col-12 lg:col-8 xl:col-6">
                    <GoogleDriveIntegrationCard />
                </div>
            </div>
            <Toast />
            <ConfirmDialog />
        </div>
    </template>
  • {
        path: '/settings/cloud-integrations',
        name: 'CloudIntegrations',
        component: () => import('@/views/integrations/CloudIntegrationsView.vue'),
        meta: { requiresAuth: true, requiresAdmin: true }
    }
  • git add src/views/integrations/ src/router/index.js <menu file>
    git commit -m "feat(cloud-integration): add CloudIntegrationsView page + route + menu entry"

Task 20: Playwright 驗收(前後端整合)

Files: N/A(用 Playwright MCP 操作 dev server)

  • cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be
    python main_app.py
  • cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-fe
    npm run dev
    • 登入(用 admin 帳號)
    • /settings/cloud-integrations
    • 截圖確認頁面有 Google Drive 卡片,狀態為「未連線」
    • 點「連線 Google Drive」按鈕,截圖確認彈出 OAuth popup(會跳到 Google)

Task 21: Changelog

Files:

  • Create: docs/changelog/<YYYY-MM-DD>-google-drive-oauth-integration.md
  • # Google Drive OAuth 整合(Phase 1)
    
    日期:<YYYY-MM-DD>
    作者:raymond / Claude
    
    ## 需求說明
    
    Tenant admin 可在「雲端空間整合設定」頁完成 Google Drive OAuth 連線、查看狀態、中斷連線。為後續資料夾自動建立與檔案同步打基礎。
    
    ## 變更範圍
    
    ### 後端
    - 新增模組 `cloud_integration`
    - 新增 table `compliance.tenant_drive_integrations` (見 `scripts/sql/<...>.sql`)
    - 新增 9 個 error codes (GRC_400030-500030)
    - 新增依賴:google-api-python-client, google-auth-oauthlib, cryptography
    - Token 加密:Fernet(key 由 env `DRIVE_TOKEN_ENCRYPTION_KEY` 提供)
    
    ### 前端
    - 新增頁面 `/settings/cloud-integrations`(admin only)
    - 新增元件 `GoogleDriveIntegrationCard.vue`
    - 新增 service `CloudIntegrationService.js`
    
    ## API 變更
    
    | Method | Path | 說明 |
    |--------|------|------|
    | GET | /api/integrations/google-drive | 取得當前 tenant 連線狀態 |
    | POST | /api/integrations/google-drive/auth-url | 產生 OAuth URL(admin) |
    | GET | /api/integrations/google-drive/callback | OAuth callback(HTML) |
    | DELETE | /api/integrations/google-drive | 中斷連線(admin) |
    
    ## 環境變數
    
    - `GOOGLE_DRIVE_OAUTH_CLIENT_ID`
    - `GOOGLE_DRIVE_OAUTH_CLIENT_SECRET`
    - `GOOGLE_DRIVE_OAUTH_REDIRECT_URI`
    - `DRIVE_TOKEN_ENCRYPTION_KEY`(用 `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 產)
    
    ## 參考
    
    - Spec: docs/features/FR-016-2604-google-drive-sync/design.md §6
    - Implementation Plan: docs/features/FR-016-2604-google-drive-sync/implementation-plan-phase-1-oauth.md
  • git add docs/changelog/<YYYY-MM-DD>-google-drive-oauth-integration.md
    git commit -m "docs: add changelog for Google Drive OAuth integration phase 1"

§3

Phase 1 驗收 Checklist

完成 → 進 Phase 2。