Files
smart-org/apps/api/app/interfaces/http/routers/projects.py
T
mjalmoudy94 82cea1e690 feat(delivery): owner-only domains + idempotent route re-sync (v2 phase A3-4)
Domains are infrastructure, and in v2 infrastructure is the platform owner's
alone (D9). A tenant gets one automatic demo host and nothing else.

- admin_domains.py (owner-only): list every route with its owning app, attach an
  extra host, detach with the host typed back as confirmation (audited), re-sync
  the router from the database, and read the suggested MCP gateway route.
- domain/platform/rules.py: validate_extra_host. Two shapes only — a single
  label under the managed root, or a full custom FQDN when ALLOW_CUSTOM_DOMAINS
  is on. A wildcard is refused in both (it would match another tenant's
  subdomain), and so are 30 protected platform labels (git, mcp, panel, api...).
  Enforced in the router *and* again in the adapter.
- Both router adapters gained routes_fingerprint / list_all_routes /
  sync_routes. Re-sync writes and reloads only when the rendered state differs:
  a reload drops every in-flight connection on the host, including live demos.
  Adapter-level extras, not additions to the frozen DomainRouterPort.
- The member-facing preview-domain routes are owner-only now. They are kept
  because the demo host is still created through them at app-creation time.

Tests: 473 API (+58); ruff and mypy clean.

Report: docs/plan/reports/agent-3-phase-4.md
2026-10-14 00:57:08 +03:30

390 lines
13 KiB
Python

"""Project, repository and preview-domain endpoints.
All routes are nested under ``/organizations/{org_id}/projects`` so the tenant
context is resolved by ``get_org_context`` before any handler body runs.
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.project_service import ProjectService
from app.core.database import get_session
from app.core.errors import NotFoundError
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_PROJECTS, User
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_org_context,
get_project_service,
require_platform_owner,
)
router = APIRouter(
prefix="/organizations/{org_id}/projects", tags=["projects"]
)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class ProjectOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
organization_id: str
name: str
slug: str
description: str
default_branch: str
created_at: datetime
class CreateProjectRequest(BaseModel):
name: str = Field(min_length=2, max_length=160)
description: str = Field(default="", max_length=2000)
class RepoOut(BaseModel):
id: str
provider: str
owner: str
repo_name: str
clone_url: str
web_url: str
default_branch: str
private: bool = True
class RepoStatusOut(BaseModel):
exists: bool
private: bool
default_branch: str
branches: list[str]
open_pull_requests: int
class CreateBranchRequest(BaseModel):
branch_name: str = Field(min_length=1, max_length=120)
from_branch: str = Field(default="main", max_length=120)
class BranchOut(BaseModel):
name: str
sha: str
class OpenPullRequestRequest(BaseModel):
head: str = Field(min_length=1, max_length=120)
base: str = Field(default="main", max_length=120)
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
class PullRequestOut(BaseModel):
number: int
url: str
state: str
class PreviewDomainOut(BaseModel):
id: str
host: str
port: int
status: str
protected: bool
url: str
class CreatePreviewDomainRequest(BaseModel):
subdomain: str = Field(min_length=3, max_length=40)
port: int | None = Field(default=None, ge=1, le=65535)
class AllocatePortRequest(BaseModel):
service_name: str = Field(default="web", max_length=80)
class PortOut(BaseModel):
port: int
service_name: str
# ---------------------------------------------------------------------------
# Projects
# ---------------------------------------------------------------------------
@router.post("", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
async def create_project(
payload: CreateProjectRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
session: AsyncSession = Depends(get_session),
) -> ProjectOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.create_project(
organization_id=context.org_id,
organization_slug=context.organization.slug,
created_by_user_id=context.user.id,
name=payload.name,
description=payload.description,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.PROJECT_CREATE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="project", target_id=project.id,
metadata={"name": project.name, "slug": project.slug}, ip=ip, user_agent=ua,
)
return ProjectOut(
id=project.id, organization_id=project.organization_id, name=project.name,
slug=project.slug, description=project.description,
default_branch=project.default_branch, created_at=project.created_at,
)
@router.get("", response_model=list[ProjectOut])
async def list_projects(
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[ProjectOut]:
projects = await service.projects.list_for_org(context.org_id, limit=limit, offset=offset)
return [
ProjectOut(
id=p.id, organization_id=p.organization_id, name=p.name, slug=p.slug,
description=p.description, default_branch=p.default_branch,
created_at=p.created_at,
)
for p in projects
]
@router.get("/{project_id}", response_model=ProjectOut)
async def get_project(
project_id: str,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> ProjectOut:
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
return ProjectOut(
id=project.id, organization_id=project.organization_id, name=project.name,
slug=project.slug, description=project.description,
default_branch=project.default_branch, created_at=project.created_at,
)
# ---------------------------------------------------------------------------
# Repository
# ---------------------------------------------------------------------------
@router.post("/{project_id}/repo/ensure", response_model=RepoOut)
async def ensure_repo(
project_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
session: AsyncSession = Depends(get_session),
) -> RepoOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
repo = await service.ensure_repository(
organization_slug=context.organization.slug, project=project
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.REPO_CREATE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="repository", target_id=repo.id,
metadata={"repo_name": repo.repo_name, "provider": repo.provider},
ip=ip, user_agent=ua,
)
return RepoOut(
id=repo.id, provider=repo.provider, owner=repo.owner, repo_name=repo.repo_name,
clone_url=repo.clone_url, web_url=repo.web_url,
default_branch=repo.default_branch, private=True,
)
@router.get("/{project_id}/repo", response_model=RepoOut)
async def get_repo(
project_id: str,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> RepoOut:
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
repo = await service.repos.get_for_project(project.id)
if repo is None:
raise NotFoundError("مخزنی برای این پروژه ثبت نشده است.")
# clone_url is a plain https URL; credentials are never included.
return RepoOut(
id=repo.id, provider=repo.provider, owner=repo.owner, repo_name=repo.repo_name,
clone_url=repo.clone_url, web_url=repo.web_url,
default_branch=repo.default_branch, private=True,
)
@router.get("/{project_id}/repo/status", response_model=RepoStatusOut)
async def get_repo_status(
project_id: str,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> RepoStatusOut:
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
status_result = await service.repo_status(project=project)
return RepoStatusOut(
exists=status_result.exists,
private=status_result.private,
default_branch=status_result.default_branch,
branches=list(status_result.branches),
open_pull_requests=status_result.open_pull_requests,
)
@router.post("/{project_id}/repo/branches", response_model=BranchOut)
async def create_branch(
project_id: str,
payload: CreateBranchRequest,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> BranchOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
branch = await service.git.create_branch(
project_id=project.id, branch_name=payload.branch_name, from_branch=payload.from_branch
)
return BranchOut(name=branch.name, sha=branch.sha)
@router.post("/{project_id}/repo/pull-requests", response_model=PullRequestOut)
async def open_pull_request(
project_id: str,
payload: OpenPullRequestRequest,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> PullRequestOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
pr = await service.git.open_pull_request(
project_id=project.id, head=payload.head, base=payload.base,
title=payload.title, body=payload.body,
)
return PullRequestOut(number=pr.number, url=pr.url, state=pr.state)
# ---------------------------------------------------------------------------
# Preview domains
# ---------------------------------------------------------------------------
@router.post("/{project_id}/preview/ports", response_model=PortOut)
async def allocate_port(
project_id: str,
payload: AllocatePortRequest,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> PortOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
port = await service.allocate_port(project=project, service_name=payload.service_name)
return PortOut(port=port, service_name=payload.service_name)
@router.post(
"/{project_id}/preview/domains",
response_model=PreviewDomainOut,
status_code=status.HTTP_201_CREATED,
)
async def create_preview_domain(
project_id: str,
payload: CreatePreviewDomainRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
session: AsyncSession = Depends(get_session),
owner: User = Depends(require_platform_owner),
) -> PreviewDomainOut:
# v2 (D9): attaching a host writes a routing rule and a certificate on a
# shared host — infrastructure, and infrastructure is the platform owner's
# alone. The demo host is still created through this route at app-creation
# time; the owner is simply the only actor who can reach it.
context.require(CAN_MANAGE_PROJECTS)
_ = owner
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
record = await service.create_preview_domain(
project=project,
requested_subdomain=payload.subdomain,
created_by_user_id=context.user.id,
port=payload.port,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.DOMAIN_CREATE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="preview_domain",
target_id=record["id"],
metadata={"host": record["host"], "port": record["port"]},
ip=ip, user_agent=ua,
)
return PreviewDomainOut(
id=record["id"], host=record["host"], port=record["port"],
status=record["status"], protected=record["protected"], url=record["url"],
)
@router.get("/{project_id}/preview/domains", response_model=list[PreviewDomainOut])
async def list_preview_domains(
project_id: str,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> list[PreviewDomainOut]:
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
records = await service.list_preview_domains(project=project)
return [
PreviewDomainOut(
id=r["id"], host=r["host"], port=r["port"], status=r["status"],
protected=r["protected"], url=r["url"],
)
for r in records
]
@router.delete(
"/{project_id}/preview/domains/{domain_id}", status_code=status.HTTP_204_NO_CONTENT
)
async def delete_preview_domain(
project_id: str,
domain_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
session: AsyncSession = Depends(get_session),
owner: User = Depends(require_platform_owner),
) -> None:
context.require(CAN_MANAGE_PROJECTS)
_ = owner
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
await service.remove_preview_domain(project=project, domain_id=domain_id)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.DOMAIN_DELETE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="preview_domain", target_id=domain_id,
ip=ip, user_agent=ua,
)