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
This commit is contained in:
@@ -2,10 +2,330 @@
|
||||
|
||||
Owned by Agent 3 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
|
||||
route its full path (no prefix is applied here besides ``/api``).
|
||||
|
||||
**Every route here is platform-owner only.** That is the v2 rule (D9): a tenant
|
||||
gets its automatic `<org-slug>.<root>` demo host and nothing else. Attaching a
|
||||
host — especially a custom FQDN — means writing a routing rule and a TLS
|
||||
certificate on a shared host, which is infrastructure, and infrastructure is the
|
||||
owner's alone.
|
||||
|
||||
The member-facing preview-domain routes in ``projects.py`` are owner-only too.
|
||||
They are kept rather than deleted because the demo host is still created through
|
||||
them at app-creation time; the owner is simply the only actor who can reach them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.ports import DomainRouterPort, ReverseProxyResult
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import ConflictError, NotFoundError, ValidationError
|
||||
from app.domain.identity.models import User
|
||||
from app.domain.platform.rules import RuleViolation, validate_extra_host
|
||||
from app.infrastructure.adapters.registry import get_domain_router
|
||||
from app.infrastructure.repositories.projects import (
|
||||
PreviewDomainRepository,
|
||||
ProjectRepository,
|
||||
)
|
||||
from app.interfaces.http.deps import client_meta, require_platform_owner
|
||||
|
||||
router = APIRouter(prefix="/admin/domains", tags=["admin-domains"])
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SyncableRouter(Protocol):
|
||||
"""The adapter-level extras the owner console needs.
|
||||
|
||||
``DomainRouterPort`` is frozen by the plan, so the whole-table re-sync lives
|
||||
beside it rather than inside it. Both real adapters implement this; a router
|
||||
that does not is reported as "cannot sync" instead of crashing.
|
||||
"""
|
||||
|
||||
async def sync_routes(self, routes: list[tuple[str, int]]) -> ReverseProxyResult: ...
|
||||
|
||||
def routes_fingerprint(self) -> str: ...
|
||||
|
||||
#: The MCP gateway is a platform service, not a tenant app. It is offered as a
|
||||
#: suggested system route rather than created automatically: the owner has to
|
||||
#: point DNS at the host before a certificate can be issued.
|
||||
MCP_ROUTE_LABEL = "mcp"
|
||||
MCP_GATEWAY_PORT = settings.mcp_gateway_port
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RouteOut(BaseModel):
|
||||
id: str
|
||||
host: str
|
||||
port: int
|
||||
status: str
|
||||
protected: bool
|
||||
url: str
|
||||
organization_id: str
|
||||
project_id: str
|
||||
project_name: str = ""
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class AttachHostRequest(BaseModel):
|
||||
organization_id: str = Field(min_length=1, max_length=32)
|
||||
project_id: str = Field(min_length=1, max_length=32)
|
||||
host: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class DetachHostRequest(BaseModel):
|
||||
#: Typed back by the operator, exactly like a production deploy.
|
||||
confirm: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class SyncResultOut(BaseModel):
|
||||
ok: bool
|
||||
message: str
|
||||
routes: int
|
||||
changed: bool
|
||||
|
||||
|
||||
class SuggestedRouteOut(BaseModel):
|
||||
label: str
|
||||
host: str
|
||||
port: int
|
||||
description: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("", response_model=list[RouteOut])
|
||||
async def list_routes(
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
limit: int = Query(default=500, ge=1, le=2_000),
|
||||
) -> list[RouteOut]:
|
||||
"""Every preview route on the platform, with its owning app."""
|
||||
domains = PreviewDomainRepository(session)
|
||||
projects = ProjectRepository(session)
|
||||
|
||||
out: list[RouteOut] = []
|
||||
names: dict[str, str] = {}
|
||||
for record in await domains.list_all(limit=limit):
|
||||
project_id = record["project_id"]
|
||||
if project_id not in names:
|
||||
project = await projects.get(project_id)
|
||||
names[project_id] = project.name if project else ""
|
||||
out.append(
|
||||
RouteOut(
|
||||
id=record["id"],
|
||||
host=record["host"],
|
||||
port=record["port"],
|
||||
status=record["status"],
|
||||
protected=record["protected"],
|
||||
url=record["url"],
|
||||
organization_id=record["organization_id"],
|
||||
project_id=project_id,
|
||||
project_name=names[project_id],
|
||||
created_at=record.get("created_at"),
|
||||
)
|
||||
)
|
||||
_ = owner
|
||||
return out
|
||||
|
||||
|
||||
@router.post("", response_model=RouteOut, status_code=status.HTTP_201_CREATED)
|
||||
async def attach_host(
|
||||
payload: AttachHostRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
router_port: DomainRouterPort = Depends(get_domain_router),
|
||||
) -> RouteOut:
|
||||
"""Attach an extra host to an app.
|
||||
|
||||
The host is validated here (single label under the root, or a custom FQDN
|
||||
when ``ALLOW_CUSTOM_DOMAINS`` is on) *and* again inside the router adapter.
|
||||
Defence in depth: a rule enforced in one place is a rule that will eventually
|
||||
be bypassed.
|
||||
"""
|
||||
domains = PreviewDomainRepository(session)
|
||||
projects = ProjectRepository(session)
|
||||
|
||||
project = await projects.get(payload.project_id)
|
||||
if project is None or project.organization_id != payload.organization_id:
|
||||
raise NotFoundError("اپلیکیشن یافت نشد.")
|
||||
|
||||
try:
|
||||
host = validate_extra_host(
|
||||
payload.host,
|
||||
root_domain=settings.preview_root_domain,
|
||||
allow_custom=settings.allow_custom_domains,
|
||||
)
|
||||
except RuleViolation as exc:
|
||||
raise ValidationError(str(exc)) from exc
|
||||
|
||||
if await domains.get_by_host(host) is not None:
|
||||
raise ConflictError("این دامنه قبلاً ثبت شده است.")
|
||||
|
||||
count = await domains.count_for_project(payload.project_id)
|
||||
if count >= settings.max_subdomains_per_project:
|
||||
raise ConflictError(
|
||||
f"هر اپلیکیشن حداکثر {settings.max_subdomains_per_project} دامنه میتواند داشته باشد."
|
||||
)
|
||||
|
||||
# A new host points at whatever the app is already serving, so the owner does
|
||||
# not have to look the port up.
|
||||
target_port = await _live_port(domains, payload.project_id)
|
||||
result = await router_port.configure_reverse_proxy(host=host, port=target_port)
|
||||
if not result.ok:
|
||||
raise ValidationError(result.message or "پیکربندی مسیر ناموفق بود.")
|
||||
|
||||
record = await domains.create(
|
||||
organization_id=payload.organization_id,
|
||||
project_id=payload.project_id,
|
||||
host=host,
|
||||
port=target_port,
|
||||
created_by_user_id=owner.id,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action="domain.attach", organization_id=payload.organization_id,
|
||||
actor_user_id=owner.id, target_type="preview_domain", target_id=record["id"],
|
||||
metadata={"host": host, "port": target_port}, ip=ip, user_agent=ua,
|
||||
)
|
||||
project_name = project.name
|
||||
return RouteOut(
|
||||
id=record["id"], host=record["host"], port=record["port"],
|
||||
status=record["status"], protected=record["protected"], url=record["url"],
|
||||
organization_id=payload.organization_id, project_id=payload.project_id,
|
||||
project_name=project_name, created_at=record.get("created_at"),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{domain_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def detach_host(
|
||||
domain_id: str,
|
||||
payload: DetachHostRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
router_port: DomainRouterPort = Depends(get_domain_router),
|
||||
) -> None:
|
||||
"""Remove a route. The operator must type the host back to confirm.
|
||||
|
||||
Removing a route is what makes a live URL stop working, so it gets the same
|
||||
typed confirmation as a production deploy rather than a plain DELETE.
|
||||
"""
|
||||
domains = PreviewDomainRepository(session)
|
||||
record = await domains.get(domain_id)
|
||||
if record is None:
|
||||
raise NotFoundError("دامنه یافت نشد.")
|
||||
if payload.confirm.strip().lower() != str(record["host"]).lower():
|
||||
raise ValidationError("تأییدیه با نام دامنه مطابقت ندارد.")
|
||||
|
||||
await router_port.remove_preview_domain(domain_id=record["host"])
|
||||
await domains.delete(domain_id)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action="domain.detach", organization_id=record["organization_id"],
|
||||
actor_user_id=owner.id, target_type="preview_domain", target_id=domain_id,
|
||||
metadata={"host": record["host"]}, ip=ip, user_agent=ua,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync", response_model=SyncResultOut)
|
||||
async def sync_routes(
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
router_port: DomainRouterPort = Depends(get_domain_router),
|
||||
) -> SyncResultOut:
|
||||
"""Converge the router on the database, without a reload when nothing moved.
|
||||
|
||||
The database is the ledger; the router is a projection of it. Drift happens
|
||||
(a hand-edited snippet, a restored backup, a failed rollback) and the fix is
|
||||
always the same: re-render from the truth.
|
||||
"""
|
||||
domains = PreviewDomainRepository(session)
|
||||
records = await domains.list_all()
|
||||
desired = [(str(r["host"]), int(r["port"])) for r in records]
|
||||
|
||||
if not isinstance(router_port, SyncableRouter):
|
||||
raise ValidationError("این روتر از همگامسازی کامل پشتیبانی نمیکند.")
|
||||
|
||||
before = router_port.routes_fingerprint()
|
||||
result = await router_port.sync_routes(desired)
|
||||
after = router_port.routes_fingerprint()
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action="domain.sync", actor_user_id=owner.id,
|
||||
target_type="preview_domain", target_id="all",
|
||||
metadata={"routes": len(desired), "ok": result.ok}, ip=ip, user_agent=ua,
|
||||
)
|
||||
return SyncResultOut(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
routes=len(desired),
|
||||
changed=before != after,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/suggested", response_model=list[SuggestedRouteOut])
|
||||
async def suggested_routes(
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[SuggestedRouteOut]:
|
||||
"""System routes the owner may want to enable.
|
||||
|
||||
The MCP gateway is the only one so far. It is suggested rather than created
|
||||
because it needs a DNS record and a certificate before it can work, and
|
||||
creating it automatically would produce a host that looks live and is not.
|
||||
"""
|
||||
domains = PreviewDomainRepository(session)
|
||||
host = f"{MCP_ROUTE_LABEL}.{settings.preview_root_domain}"
|
||||
existing = await domains.get_by_host(host)
|
||||
_ = owner
|
||||
return [
|
||||
SuggestedRouteOut(
|
||||
label=MCP_ROUTE_LABEL,
|
||||
host=host,
|
||||
port=MCP_GATEWAY_PORT,
|
||||
description="دروازهٔ MCP برای اتصال عاملهای هوش مصنوعی به سامانه.",
|
||||
enabled=existing is not None,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def _live_port(domains: PreviewDomainRepository, project_id: str) -> int:
|
||||
"""The port an app is already serving on, so a new host points at the app."""
|
||||
for service_name in ("demo", "web"):
|
||||
port = await domains.get_port(project_id, service_name)
|
||||
if port is not None:
|
||||
return int(port)
|
||||
raise ValidationError("این اپلیکیشن هنوز پورتی برای مسیردهی ندارد.")
|
||||
|
||||
|
||||
def _fingerprint(router_port: DomainRouterPort) -> str:
|
||||
"""The router's own digest of its routes, when it offers one."""
|
||||
getter = getattr(router_port, "routes_fingerprint", None)
|
||||
if getter is None:
|
||||
return ""
|
||||
try:
|
||||
return str(getter())
|
||||
except Exception: # noqa: BLE001 - a fingerprint is a convenience, not a fact
|
||||
return ""
|
||||
|
||||
router = APIRouter(tags=["admin-domains"])
|
||||
|
||||
@@ -17,8 +17,14 @@ 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
|
||||
from app.interfaces.http.deps import OrgContext, client_meta, get_org_context, get_project_service
|
||||
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"]
|
||||
@@ -310,8 +316,14 @@ async def create_preview_domain(
|
||||
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(
|
||||
@@ -362,8 +374,10 @@ async def delete_preview_domain(
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user