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:
mjalmoudy94
2026-10-14 00:57:08 +03:30
parent a1b8a28dda
commit 82cea1e690
8 changed files with 1059 additions and 4 deletions
+98
View File
@@ -123,6 +123,104 @@ def assert_port_allocatable(
return port
# ---------------------------------------------------------------------------
# Extra hosts (v2: only the platform owner may attach one)
# ---------------------------------------------------------------------------
#: Labels that must never become a tenant host. They are either platform
#: services or would shadow one: `git` is the forge, `mcp` the MCP gateway,
#: `panel`/`admin` the console, `api` the backend itself. A tenant host that
#: resolves to one of these is a phishing surface and, worse, a routing
#: conflict with a real service.
PROTECTED_HOST_LABELS: frozenset[str] = frozenset(
{
"admin",
"api",
"app",
"assets",
"billing",
"cdn",
"console",
"dashboard",
"ftp",
"git",
"gitea",
"grafana",
"internal",
"mail",
"mcp",
"media",
"monitor",
"ns",
"panel",
"registry",
"sandbox",
"smtp",
"ssh",
"static",
"status",
"storage",
"support",
"webhook",
"www",
}
)
#: A strict fully-qualified domain name. Deliberately does **not** allow a
#: wildcard (`*.example.com` in a Caddy site block matches every subdomain,
#: including one belonging to another tenant) and requires at least one dot.
_FQDN_RE = re.compile(
r"^(?!-)[a-z0-9-]{1,63}(?<!-)(?:\.(?!-)[a-z0-9-]{1,63}(?<!-))+$"
)
#: Longest host we will ever write into a config file.
MAX_HOST_LENGTH = 253
def validate_extra_host(
host: str, *, root_domain: str = DEFAULT_ROOT_DOMAIN, allow_custom: bool = False
) -> str:
"""Validate a host the owner wants to attach to an app.
Two shapes are allowed:
* a **single label under the managed root** (``shop.msa.artacloud.ir``), which
is the default and needs no DNS work from anyone; and
* a **full custom FQDN** (``app.customer.com``), but only when the operator
has turned ``ALLOW_CUSTOM_DOMAINS`` on — a custom host needs its own TLS
certificate and DNS record, and silently accepting one that will never
resolve produces a domain that looks configured and is not.
A wildcard is refused in both cases. So is anything whose first label is a
protected platform name, in either shape.
"""
clean = (host or "").strip().lower().rstrip(".")
if not clean:
raise RuleViolation("نام دامنه خالی است.")
if len(clean) > MAX_HOST_LENGTH:
raise RuleViolation("نام دامنه بیش از حد طولانی است.")
if "*" in clean:
raise RuleViolation("دامنهٔ wildcard مجاز نیست.")
if not _FQDN_RE.match(clean):
raise RuleViolation("قالب نام دامنه نامعتبر است.")
first_label = clean.split(".", 1)[0]
if first_label in PROTECTED_HOST_LABELS:
raise RuleViolation(f"برچسب «{first_label}» برای سرویس‌های سکو رزرو شده است.")
suffix = f".{root_domain.strip().lower()}"
if clean.endswith(suffix):
return validate_managed_host(clean, root_domain)
if clean == root_domain.strip().lower():
raise RuleViolation("دامنهٔ ریشه قابل استفاده نیست.")
if not allow_custom:
raise RuleViolation(
"افزودن دامنهٔ سفارشی غیرفعال است؛ فقط زیردامنه‌های سکو مجازند."
)
return clean
def assert_not_protected_port(port: int) -> int:
"""A preview domain must never point at an internal platform port."""
if port in SYSTEM_PROTECTED_PORTS:
@@ -38,6 +38,7 @@ from tenant code: tenant commands only ever run inside a sandbox adapter.
from __future__ import annotations
import asyncio
import hashlib
import re
from pathlib import Path
@@ -207,6 +208,80 @@ class CaddyDomainRouterAdapter:
for e in self._parse(self._read_snippet())
]
# -- full re-sync (owner console) ----------------------------------------
def routes_fingerprint(self) -> str:
"""A stable digest of the routes currently in the snippet.
The owner console's "re-sync" is a convenience button, and pressing it
must not reload Caddy when nothing changed: a reload drops every
in-flight connection on the host, including tenants' live demos.
"""
entries = sorted(self._parse(self._read_snippet()), key=lambda e: e.host)
material = "\n".join(f"{e.host}:{e.port}" for e in entries)
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]
async def list_all_routes(self) -> list[PreviewDomainInfo]:
"""Every managed route, with no tenant filter (owner console only)."""
return [
PreviewDomainInfo(host=e.host, port=e.port, url=f"https://{e.host}")
for e in sorted(self._parse(self._read_snippet()), key=lambda e: e.host)
]
async def sync_routes(self, routes: list[tuple[str, int]]) -> ReverseProxyResult:
"""Make the snippet match ``routes`` exactly, in one write and one reload.
Idempotent by construction: the desired state is rendered, compared
against what is on disk, and written only when it differs. This is what
makes "re-sync from the database" a safe thing to expose as a button —
it converges on the truth rather than accumulating edits.
"""
try:
entries = [
_Entry(host=validate_managed_host(host, self.root_domain), port=int(port))
for host, port in routes
]
except (RuleViolation, ValueError) as exc:
return ReverseProxyResult(ok=False, message=str(exc))
for entry in entries:
try:
validate_proxy_target(f"localhost:{entry.port}")
assert_port_allocatable(entry.port, start=self.port_start, end=self.port_end)
assert_not_protected_port(entry.port)
except RuleViolation as exc:
return ReverseProxyResult(ok=False, message=str(exc))
if not self._import_is_wired():
return ReverseProxyResult(
ok=False,
message=(
"فایل Caddyfile خط import مربوط به AGY را ندارد. "
f"این خط را اضافه کنید: import {self.snippet_dir}/*.caddy"
),
)
previous = self._read_snippet()
updated = self._render(entries)
if updated == previous:
# Nothing to do. A reload here would drop every live connection for
# no reason at all.
return ReverseProxyResult(
ok=True, message="مسیرها از قبل همگام بودند.", url=""
)
self._write_snippet(updated)
error = await self._validate()
if error is None:
error = await self._reload()
if error is not None:
self._write_snippet(previous)
await self._reload()
return ReverseProxyResult(ok=False, message=error)
return ReverseProxyResult(
ok=True, message=f"{len(entries)} مسیر همگام شد.", url=""
)
# -- file handling -------------------------------------------------------
def _read_snippet(self) -> str:
@@ -36,6 +36,8 @@ from app.application.ports import (
WorkspaceInfo,
)
from app.domain.platform.rules import (
RuleViolation,
assert_not_protected_port,
assert_port_allocatable,
build_host,
validate_managed_host,
@@ -250,6 +252,49 @@ class MockDomainRouterAdapter:
if h in self._domains
]
# -- full re-sync (owner console) ----------------------------------------
def routes_fingerprint(self) -> str:
"""Stable digest of the routes currently registered."""
material = "\n".join(sorted(f"{h}:{i.port}" for h, i in self._domains.items()))
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]
async def list_all_routes(self) -> list[PreviewDomainInfo]:
return [self._domains[h] for h in sorted(self._domains)]
async def sync_routes(self, routes: list[tuple[str, int]]) -> ReverseProxyResult:
"""Make the mock ledger match ``routes`` exactly.
Applies the same validation as the Caddy adapter, so a test that passes
against the mock describes behaviour the real adapter also guarantees.
"""
try:
desired = {
validate_managed_host(host, self.root_domain): int(port)
for host, port in routes
}
except (RuleViolation, ValueError) as exc:
return ReverseProxyResult(ok=False, message=str(exc))
for port in desired.values():
try:
validate_proxy_target(f"localhost:{port}")
assert_not_protected_port(port)
except RuleViolation as exc:
return ReverseProxyResult(ok=False, message=str(exc))
if {h: i.port for h, i in self._domains.items()} == desired:
return ReverseProxyResult(ok=True, message="مسیرها از قبل همگام بودند.")
for host in list(self._domains):
if host not in desired:
self._domains.pop(host, None)
for host, port in desired.items():
self._domains[host] = PreviewDomainInfo(
host=host, port=port, url=f"https://{host}", status="active"
)
return ReverseProxyResult(ok=True, message=f"{len(desired)} مسیر همگام شد.")
# ---------------------------------------------------------------------------
# Sandbox
@@ -294,6 +294,20 @@ class PreviewDomainRepository:
async def count_for_project(self, project_id: str) -> int:
return len(await self.list_for_project(project_id))
async def list_all(self, *, limit: int = 2_000) -> list[dict]:
"""Every preview domain on the platform.
Deliberately **not tenant-scoped**: this is the owner console's view of
the whole routing table, and it is reachable only from an
``require_platform_owner`` route. Never use it for authorization.
"""
stmt = (
select(PreviewDomainRow)
.order_by(PreviewDomainRow.host.asc())
.limit(min(max(limit, 1), 5_000))
)
return [self._row_to_dict(r) for r in (await self.session.execute(stmt)).scalars()]
async def create(
self,
*,
@@ -336,4 +350,7 @@ class PreviewDomainRepository:
"protected": row.protected,
"created_by_user_id": row.created_by_user_id,
"created_at": row.created_at,
# Derived, not stored: the scheme is always https and the host is the
# identity. Kept here so every caller renders the same URL.
"url": f"https://{row.host}",
}
@@ -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)