Files
mjalmoudy94 72408a0a4f feat(delivery): production deploy over SSH/SFTP/FTP/FTPS (v2 phase A3-5)
The one path that touches a customer's server, and the one the platform cannot
undo. It is human-triggered, confirmed and audited; there is deliberately no
code path from a pipeline run into it (D13).

- deploy_ssh.py: SshDeployer. Release lands in releases/<ts>-<sha7>/, the
  post-deploy command runs with cwd there, and only then does `current` move —
  atomically (relative symlink + mv -T). A failed hook or extraction never moves
  it, so the previous release keeps serving. Host key is pinned on first success
  (TOFU); a mismatch is a hard failure. Prunes to the newest five.
- deploy_ftp.py: FtpDeployer. FTP has no atomic switch and no rollback except a
  backup, so a backup is mandatory: every file about to be overwritten is
  archived under ARTIFACTS_ROOT before anything is uploaded. The artifact is
  extracted, then the build dir (dist/build/public/out) is chosen — a static
  deploy must not publish the repository. Archive members are sanitised: absolute
  paths, `..` and escaping links are refused.
- deploy_target.py: the tester that dispatches on `kind`.
- deploy_service.py + production_deploys (migration a3b4c5d6e7f4) + 4 routes.
  One deploy per project at a time; the org slug must be typed back; the target
  summary reports has_secret, never the secret.

Tests: 499 API (+26) with fake SSH/FTP transports; ruff, mypy, alembic clean.

Report: docs/plan/reports/agent-3-phase-5.md
2026-10-14 01:16:23 +03:30

216 lines
7.7 KiB
Python

"""FastAPI dependencies: authentication, tenant resolution and capability checks.
The single most important rule of this file: **every** organization-scoped
endpoint resolves its tenant through ``get_org_context``, which refuses to
return anything unless the caller has an active membership. There is no
"trust the org_id in the path" path.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.core.errors import AuthenticationError, NotFoundError, PermissionDeniedError
from app.core.security import decode_access_token
from app.domain.identity.models import Membership, Organization, Role, User
from app.infrastructure.repositories.identity import (
MembershipRepository,
OrganizationRepository,
UserRepository,
)
from app.interfaces.http.request_context import note_actor
if TYPE_CHECKING:
from app.application.services.billing_service import BillingService
from app.application.services.knowledge_service import KnowledgeService
from app.application.services.project_service import ProjectService
from app.application.services.sandbox_service import SandboxService
from app.application.services.workforce_service import WorkforceService
_bearer = HTTPBearer(auto_error=False)
@dataclass(slots=True)
class OrgContext:
"""Resolved tenant context for an organization-scoped request."""
organization: Organization
membership: Membership
user: User
@property
def org_id(self) -> str:
return self.organization.id
def require(self, capability: str) -> None:
if not self.membership.has(capability):
raise PermissionDeniedError("شما دسترسی لازم برای این عملیات را ندارید.")
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
session: AsyncSession = Depends(get_session),
) -> User:
if credentials is None or not credentials.credentials:
raise AuthenticationError("برای این عملیات باید وارد شوید.")
payload = decode_access_token(credentials.credentials)
user_id = payload.get("sub")
if not user_id:
raise AuthenticationError("توکن نامعتبر است.")
user = await UserRepository(session).get(str(user_id))
if user is None or not user.is_active:
raise AuthenticationError("حساب کاربری یافت نشد یا غیرفعال است.")
# Record the verified actor so any later failure is attributable. Uses the
# id from the database, not the one in the token.
note_actor(request, user_id=user.id)
return user
async def get_optional_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
session: AsyncSession = Depends(get_session),
) -> User | None:
if credentials is None or not credentials.credentials:
return None
try:
return await get_current_user(request, credentials, session)
except AuthenticationError:
return None
async def require_platform_owner(user: User = Depends(get_current_user)) -> User:
if not user.is_platform_owner:
raise PermissionDeniedError("این بخش فقط برای مالک سامانه قابل دسترسی است.")
return user
async def get_org_context(
request: Request,
org_id: str,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> OrgContext:
"""Resolve ``org_id`` into an authorized tenant context.
Returns 404 (not 403) when the caller has no membership, so the API does not
leak the existence of other organizations.
"""
organization = await OrganizationRepository(session).get(org_id)
if organization is None:
raise NotFoundError("سازمان یافت نشد.")
membership = await MembershipRepository(session).get(org_id, user.id)
if membership is None or not membership.is_active:
raise NotFoundError("سازمان یافت نشد.")
# The tenant is only known *after* the membership check passes, so this is
# the earliest point at which it is safe to attribute a failure to an org.
note_actor(request, organization_id=organization.id, user_id=user.id)
return OrgContext(organization=organization, membership=membership, user=user)
def require_capability(capability: str):
"""Dependency factory: ``Depends(require_capability(CAN_MANAGE_PROJECTS))``."""
async def _checker(context: OrgContext = Depends(get_org_context)) -> OrgContext:
context.require(capability)
return context
return _checker
def require_role_at_least(minimum: Role):
from app.domain.identity.models import ROLE_RANK
async def _checker(context: OrgContext = Depends(get_org_context)) -> OrgContext:
if ROLE_RANK[context.membership.role] < ROLE_RANK[minimum]:
raise PermissionDeniedError("سطح دسترسی شما برای این عملیات کافی نیست.")
return context
return _checker
def client_meta(request: Request) -> tuple[str, str]:
"""``(ip, user_agent)`` for the audit trail."""
forwarded = request.headers.get("x-forwarded-for", "")
ip = (
forwarded.split(",")[0].strip()
if forwarded
else (request.client.host if request.client else "")
)
return ip, request.headers.get("user-agent", "")[:255]
# The builders themselves live in ``app.application.container`` so that the
# standalone workers compose services exactly the way the API does. These
# functions are thin FastAPI dependency wrappers around them.
async def get_project_service(
session: AsyncSession = Depends(get_session),
) -> ProjectService:
"""Build the project/domain service with the configured adapters."""
from app.application.container import build_project_service
return build_project_service(session)
async def get_workforce_service(
session: AsyncSession = Depends(get_session),
) -> WorkforceService:
"""Build the workforce/chat/run service with the configured adapters."""
from app.application.container import build_workforce_service
return build_workforce_service(session)
async def get_billing_service(
session: AsyncSession = Depends(get_session),
) -> BillingService:
"""Build the billing service with the configured payment adapter."""
from app.application.container import build_billing_service
return build_billing_service(session)
async def get_knowledge_service(
session: AsyncSession = Depends(get_session),
) -> KnowledgeService:
"""Build the knowledge service with the configured vector adapter."""
from app.application.container import build_knowledge_service
return build_knowledge_service(session)
async def get_sandbox_service(
session: AsyncSession = Depends(get_session),
) -> SandboxService:
"""Build the sandbox gateway with the configured execution adapter."""
from app.application.container import build_sandbox_service
return build_sandbox_service(session)
async def get_delivery_service(
session: AsyncSession = Depends(get_session),
):
"""Build the CI/CD service with the configured workspace, router and runtime."""
from app.application.container import build_delivery_service
return build_delivery_service(session)
async def get_deploy_service(
session: AsyncSession = Depends(get_session),
):
"""Build the production deploy service with the configured transports."""
from app.application.container import build_deploy_service
return build_deploy_service(session)