A push to `dev` now becomes a live demo, and a failed build never takes the demo down. - Four tables (pipeline_runs, pipeline_steps, releases, app_runtimes) and their migration; alembic upgrade/downgrade/check all clean. - DeliveryService implements DeliveryHooks: on_app_created provisions the demo port + host + first build (idempotently); on_branch_pushed queues a run for `dev` and supersedes older *queued* runs, leaving a running one alone. - The 8-step pipeline. Blue/green is real: the replacement starts on a staging port, is healthchecked, and only then does the route move — then the previous container is stopped. A failure removes the replacement, releases its port and leaves the old container serving. - The demo port ledger is the DATABASE, not the router adapters' in-memory maps: the API and the delivery worker are separate processes. - Runs are claimed with a conditional UPDATE, so two workers never build the same run. - Live logs are batched (1 event / 500 ms, 200 lines) and redacted as a whole tail — per-chunk redaction misses a secret split across pipe reads, and the *stored* tail leaked until this was fixed. - delivery worker: drain the queue, then poll remotes for pushes made outside the engine. - 7 routes; reads need membership, actions need projects.manage and are audited. Tests: 415 API (+21, 1 docker-marked skipped); ruff, mypy, alembic check clean. Report: docs/plan/reports/agent-3-phase-3.md
207 lines
7.4 KiB
Python
207 lines
7.4 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)
|