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:
@@ -123,6 +123,104 @@ def assert_port_allocatable(
|
|||||||
return port
|
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:
|
def assert_not_protected_port(port: int) -> int:
|
||||||
"""A preview domain must never point at an internal platform port."""
|
"""A preview domain must never point at an internal platform port."""
|
||||||
if port in SYSTEM_PROTECTED_PORTS:
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -207,6 +208,80 @@ class CaddyDomainRouterAdapter:
|
|||||||
for e in self._parse(self._read_snippet())
|
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 -------------------------------------------------------
|
# -- file handling -------------------------------------------------------
|
||||||
|
|
||||||
def _read_snippet(self) -> str:
|
def _read_snippet(self) -> str:
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ from app.application.ports import (
|
|||||||
WorkspaceInfo,
|
WorkspaceInfo,
|
||||||
)
|
)
|
||||||
from app.domain.platform.rules import (
|
from app.domain.platform.rules import (
|
||||||
|
RuleViolation,
|
||||||
|
assert_not_protected_port,
|
||||||
assert_port_allocatable,
|
assert_port_allocatable,
|
||||||
build_host,
|
build_host,
|
||||||
validate_managed_host,
|
validate_managed_host,
|
||||||
@@ -250,6 +252,49 @@ class MockDomainRouterAdapter:
|
|||||||
if h in self._domains
|
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
|
# Sandbox
|
||||||
|
|||||||
@@ -294,6 +294,20 @@ class PreviewDomainRepository:
|
|||||||
async def count_for_project(self, project_id: str) -> int:
|
async def count_for_project(self, project_id: str) -> int:
|
||||||
return len(await self.list_for_project(project_id))
|
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(
|
async def create(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -336,4 +350,7 @@ class PreviewDomainRepository:
|
|||||||
"protected": row.protected,
|
"protected": row.protected,
|
||||||
"created_by_user_id": row.created_by_user_id,
|
"created_by_user_id": row.created_by_user_id,
|
||||||
"created_at": row.created_at,
|
"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
|
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``).
|
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 __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.database import get_session
|
||||||
from app.core.errors import NotFoundError
|
from app.core.errors import NotFoundError
|
||||||
from app.domain.audit.models import AuditAction
|
from app.domain.audit.models import AuditAction
|
||||||
from app.domain.identity.models import CAN_MANAGE_PROJECTS
|
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
|
from app.interfaces.http.deps import (
|
||||||
|
OrgContext,
|
||||||
|
client_meta,
|
||||||
|
get_org_context,
|
||||||
|
get_project_service,
|
||||||
|
require_platform_owner,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/organizations/{org_id}/projects", tags=["projects"]
|
prefix="/organizations/{org_id}/projects", tags=["projects"]
|
||||||
@@ -310,8 +316,14 @@ async def create_preview_domain(
|
|||||||
context: OrgContext = Depends(get_org_context),
|
context: OrgContext = Depends(get_org_context),
|
||||||
service: ProjectService = Depends(get_project_service),
|
service: ProjectService = Depends(get_project_service),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
owner: User = Depends(require_platform_owner),
|
||||||
) -> PreviewDomainOut:
|
) -> 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)
|
context.require(CAN_MANAGE_PROJECTS)
|
||||||
|
_ = owner
|
||||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||||
|
|
||||||
record = await service.create_preview_domain(
|
record = await service.create_preview_domain(
|
||||||
@@ -362,8 +374,10 @@ async def delete_preview_domain(
|
|||||||
context: OrgContext = Depends(get_org_context),
|
context: OrgContext = Depends(get_org_context),
|
||||||
service: ProjectService = Depends(get_project_service),
|
service: ProjectService = Depends(get_project_service),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
owner: User = Depends(require_platform_owner),
|
||||||
) -> None:
|
) -> None:
|
||||||
context.require(CAN_MANAGE_PROJECTS)
|
context.require(CAN_MANAGE_PROJECTS)
|
||||||
|
_ = owner
|
||||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
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)
|
await service.remove_preview_domain(project=project, domain_id=domain_id)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"""§v2-A3.4 — domains belong to the platform owner (D9).
|
||||||
|
|
||||||
|
A tenant gets one automatic demo host and nothing else. Attaching another host —
|
||||||
|
especially a custom FQDN — means writing a routing rule and a TLS certificate on
|
||||||
|
a host shared by every tenant, and that is infrastructure.
|
||||||
|
|
||||||
|
The tests below are mostly about *refusals*, because that is where the security
|
||||||
|
lives: a wildcard, a protected label, a host that belongs to someone else, an
|
||||||
|
unconfirmed detach, and a non-owner calling any of it. The re-sync test asserts
|
||||||
|
the property that makes the button safe to press: when nothing changed, nothing
|
||||||
|
is reloaded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.domain.identity.models import PlatformRole
|
||||||
|
from app.domain.platform.rules import (
|
||||||
|
PROTECTED_HOST_LABELS,
|
||||||
|
RuleViolation,
|
||||||
|
validate_extra_host,
|
||||||
|
)
|
||||||
|
from app.infrastructure.adapters.mock import MockDomainRouterAdapter
|
||||||
|
from tests.conftest import auth_header, signup
|
||||||
|
|
||||||
|
ROOT = "msa.artacloud.ir"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# the rule itself
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"host",
|
||||||
|
[
|
||||||
|
"shop.msa.artacloud.ir",
|
||||||
|
"app-2.msa.artacloud.ir",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_single_label_under_the_root_is_always_allowed(host: str):
|
||||||
|
assert validate_extra_host(host, root_domain=ROOT) == host
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"host",
|
||||||
|
[
|
||||||
|
"*",
|
||||||
|
"*.example.com",
|
||||||
|
"shop.*.msa.artacloud.ir",
|
||||||
|
"no-dot",
|
||||||
|
"-leading.msa.artacloud.ir",
|
||||||
|
"trailing-.msa.artacloud.ir",
|
||||||
|
"under_score.msa.artacloud.ir",
|
||||||
|
"shop .msa.artacloud.ir",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_malformed_hosts_are_refused(host: str):
|
||||||
|
with pytest.raises(RuleViolation):
|
||||||
|
validate_extra_host(host, root_domain=ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("label", sorted(PROTECTED_HOST_LABELS))
|
||||||
|
def test_protected_platform_labels_are_refused(label: str):
|
||||||
|
with pytest.raises(RuleViolation):
|
||||||
|
validate_extra_host(f"{label}.{ROOT}", root_domain=ROOT)
|
||||||
|
with pytest.raises(RuleViolation):
|
||||||
|
validate_extra_host(f"{label}.example.com", root_domain=ROOT, allow_custom=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_custom_fqdn_needs_the_operator_switch():
|
||||||
|
with pytest.raises(RuleViolation):
|
||||||
|
validate_extra_host("shop.customer.example", root_domain=ROOT, allow_custom=False)
|
||||||
|
assert (
|
||||||
|
validate_extra_host("shop.customer.example", root_domain=ROOT, allow_custom=True)
|
||||||
|
== "shop.customer.example"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_root_domain_itself_is_never_attachable():
|
||||||
|
with pytest.raises(RuleViolation):
|
||||||
|
validate_extra_host(ROOT, root_domain=ROOT, allow_custom=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hosts_are_normalised():
|
||||||
|
assert (
|
||||||
|
validate_extra_host(" SHOP.MSA.ARTACLOUD.IR. ", root_domain=ROOT)
|
||||||
|
== "shop.msa.artacloud.ir"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# re-sync is idempotent
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_does_nothing_when_nothing_changed():
|
||||||
|
router = MockDomainRouterAdapter(root_domain=ROOT)
|
||||||
|
await router.sync_routes([("alpha.msa.artacloud.ir", 8100)])
|
||||||
|
before = router.routes_fingerprint()
|
||||||
|
|
||||||
|
result = await router.sync_routes([("alpha.msa.artacloud.ir", 8100)])
|
||||||
|
|
||||||
|
assert result.ok is True
|
||||||
|
assert "از قبل همگام" in result.message
|
||||||
|
assert router.routes_fingerprint() == before
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_converges_on_the_desired_state():
|
||||||
|
router = MockDomainRouterAdapter(root_domain=ROOT)
|
||||||
|
await router.sync_routes([("stale.msa.artacloud.ir", 8100)])
|
||||||
|
|
||||||
|
result = await router.sync_routes(
|
||||||
|
[("alpha.msa.artacloud.ir", 8101), ("beta.msa.artacloud.ir", 8102)]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.ok is True
|
||||||
|
hosts = {r.host for r in await router.list_all_routes()}
|
||||||
|
assert hosts == {"alpha.msa.artacloud.ir", "beta.msa.artacloud.ir"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_refuses_an_unmanaged_host():
|
||||||
|
router = MockDomainRouterAdapter(root_domain=ROOT)
|
||||||
|
result = await router.sync_routes([("not-ours.example.com", 8100)])
|
||||||
|
assert result.ok is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP surface
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(autouse=True)
|
||||||
|
async def _platform_seed(seeded):
|
||||||
|
return seeded
|
||||||
|
|
||||||
|
|
||||||
|
async def _owner(client: AsyncClient) -> dict:
|
||||||
|
token = (
|
||||||
|
await signup(
|
||||||
|
client,
|
||||||
|
"domains-owner@example.com",
|
||||||
|
name="مالک سامانه",
|
||||||
|
platform_role=PlatformRole.OWNER,
|
||||||
|
)
|
||||||
|
)["access_token"]
|
||||||
|
headers = auth_header(token)
|
||||||
|
org_id = (
|
||||||
|
await client.post("/api/organizations", json={"name": "سازمان دامنه"}, headers=headers)
|
||||||
|
).json()["id"]
|
||||||
|
project_id = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/organizations/{org_id}/projects",
|
||||||
|
json={"name": "اپ دامنه"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
return {"org_id": org_id, "project_id": project_id, "headers": headers}
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_domain(session_factory, owner: dict, host: str, port: int) -> str:
|
||||||
|
from app.infrastructure.repositories.projects import PreviewDomainRepository
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
record = await PreviewDomainRepository(session).create(
|
||||||
|
organization_id=owner["org_id"],
|
||||||
|
project_id=owner["project_id"],
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
created_by_user_id=None,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return record["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_non_owner_cannot_list_routes(client: AsyncClient):
|
||||||
|
token = (await signup(client, "not-owner@example.com"))["access_token"]
|
||||||
|
response = await client.get("/api/admin/domains", headers=auth_header(token))
|
||||||
|
assert response.status_code == 403, response.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_non_owner_cannot_attach_a_host(client: AsyncClient, owner=None):
|
||||||
|
token = (await signup(client, "not-owner-2@example.com"))["access_token"]
|
||||||
|
response = await client.post(
|
||||||
|
"/api/admin/domains",
|
||||||
|
json={"organization_id": "x", "project_id": "y", "host": "a.msa.artacloud.ir"},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert response.status_code == 403, response.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_owner_sees_every_route(client: AsyncClient, session_factory):
|
||||||
|
owner = await _owner(client)
|
||||||
|
await _seed_domain(session_factory, owner, "shown.msa.artacloud.ir", 8100)
|
||||||
|
|
||||||
|
response = await client.get("/api/admin/domains", headers=owner["headers"])
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
hosts = [r["host"] for r in response.json()]
|
||||||
|
assert "shown.msa.artacloud.ir" in hosts
|
||||||
|
assert response.json()[0]["project_name"] == "اپ دامنه"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_attaching_a_protected_host_is_refused(client: AsyncClient):
|
||||||
|
owner = await _owner(client)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/admin/domains",
|
||||||
|
json={
|
||||||
|
"organization_id": owner["org_id"],
|
||||||
|
"project_id": owner["project_id"],
|
||||||
|
"host": f"git.{ROOT}",
|
||||||
|
},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
assert response.status_code == 422, response.text
|
||||||
|
assert "رزرو" in response.json()["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_attaching_a_wildcard_is_refused(client: AsyncClient):
|
||||||
|
owner = await _owner(client)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/admin/domains",
|
||||||
|
json={
|
||||||
|
"organization_id": owner["org_id"],
|
||||||
|
"project_id": owner["project_id"],
|
||||||
|
"host": f"*.{ROOT}",
|
||||||
|
},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
assert response.status_code == 422, response.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_attaching_a_custom_domain_needs_the_switch(client: AsyncClient):
|
||||||
|
owner = await _owner(client)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/admin/domains",
|
||||||
|
json={
|
||||||
|
"organization_id": owner["org_id"],
|
||||||
|
"project_id": owner["project_id"],
|
||||||
|
"host": "shop.customer.example",
|
||||||
|
},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
assert response.status_code == 422, response.text
|
||||||
|
assert "سفارشی" in response.json()["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_attaching_a_host_to_a_foreign_app_is_not_found(client: AsyncClient):
|
||||||
|
owner = await _owner(client)
|
||||||
|
other = (await signup(client, "domains-other@example.com"))["access_token"]
|
||||||
|
other_headers = auth_header(other)
|
||||||
|
other_org = (
|
||||||
|
await client.post(
|
||||||
|
"/api/organizations", json={"name": "سازمان دیگر"}, headers=other_headers
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
other_project = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/organizations/{other_org}/projects",
|
||||||
|
json={"name": "اپ دیگر"},
|
||||||
|
headers=other_headers,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/admin/domains",
|
||||||
|
json={
|
||||||
|
"organization_id": owner["org_id"],
|
||||||
|
"project_id": other_project,
|
||||||
|
"host": f"extra-host.{ROOT}",
|
||||||
|
},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
assert response.status_code == 404, response.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_detach_requires_the_host_typed_back(client: AsyncClient, session_factory):
|
||||||
|
owner = await _owner(client)
|
||||||
|
domain_id = await _seed_domain(session_factory, owner, "detach.msa.artacloud.ir", 8100)
|
||||||
|
|
||||||
|
wrong = await client.request(
|
||||||
|
"DELETE",
|
||||||
|
f"/api/admin/domains/{domain_id}",
|
||||||
|
json={"confirm": "not-the-host"},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
assert wrong.status_code == 422, wrong.text
|
||||||
|
|
||||||
|
right = await client.request(
|
||||||
|
"DELETE",
|
||||||
|
f"/api/admin/domains/{domain_id}",
|
||||||
|
json={"confirm": "detach.msa.artacloud.ir"},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
assert right.status_code == 204, right.text
|
||||||
|
|
||||||
|
listed = await client.get("/api/admin/domains", headers=owner["headers"])
|
||||||
|
assert "detach.msa.artacloud.ir" not in [r["host"] for r in listed.json()]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_detach_is_audited(client: AsyncClient, session_factory):
|
||||||
|
owner = await _owner(client)
|
||||||
|
domain_id = await _seed_domain(session_factory, owner, "audited.msa.artacloud.ir", 8100)
|
||||||
|
|
||||||
|
await client.request(
|
||||||
|
"DELETE",
|
||||||
|
f"/api/admin/domains/{domain_id}",
|
||||||
|
json={"confirm": "audited.msa.artacloud.ir"},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
|
||||||
|
audit = await client.get(
|
||||||
|
f"/api/organizations/{owner['org_id']}/audit", headers=owner["headers"]
|
||||||
|
)
|
||||||
|
actions = {e["action"] for e in audit.json()}
|
||||||
|
assert "domain.detach" in actions
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_mcp_gateway_is_offered_as_a_suggested_route(client: AsyncClient):
|
||||||
|
owner = await _owner(client)
|
||||||
|
response = await client.get("/api/admin/domains/suggested", headers=owner["headers"])
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body[0]["label"] == "mcp"
|
||||||
|
assert body[0]["host"] == f"mcp.{ROOT}"
|
||||||
|
assert body[0]["enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_is_idempotent_over_http(client: AsyncClient, session_factory):
|
||||||
|
owner = await _owner(client)
|
||||||
|
await _seed_domain(session_factory, owner, "sync.msa.artacloud.ir", 8100)
|
||||||
|
|
||||||
|
first = await client.post("/api/admin/domains/sync", headers=owner["headers"])
|
||||||
|
second = await client.post("/api/admin/domains/sync", headers=owner["headers"])
|
||||||
|
|
||||||
|
assert first.status_code == 200, first.text
|
||||||
|
assert second.status_code == 200, second.text
|
||||||
|
assert second.json()["routes"] == 1
|
||||||
|
# The mock router reports "already in sync" on the second pass; the real
|
||||||
|
# Caddy adapter skips the reload entirely (asserted at the adapter level).
|
||||||
|
assert second.json()["changed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_member_facing_domain_route_is_owner_only(client: AsyncClient):
|
||||||
|
"""A tenant can no longer attach a host through the project route (D9)."""
|
||||||
|
token = (await signup(client, "domains-tenant@example.com"))["access_token"]
|
||||||
|
headers = auth_header(token)
|
||||||
|
org_id = (
|
||||||
|
await client.post("/api/organizations", json={"name": "سازمان مستأجر"}, headers=headers)
|
||||||
|
).json()["id"]
|
||||||
|
project_id = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/organizations/{org_id}/projects",
|
||||||
|
json={"name": "اپ مستأجر"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/organizations/{org_id}/projects/{project_id}/preview/domains",
|
||||||
|
json={"subdomain": "tenant-app"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 403, response.text
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Agent 3 — Phase 4 report: domains owned by the platform owner
|
||||||
|
|
||||||
|
Date: 2026-10-14 · Branch: `dev`
|
||||||
|
|
||||||
|
## What was done
|
||||||
|
|
||||||
|
### 4.1 `admin_domains.py` — the owner's routing console
|
||||||
|
|
||||||
|
Four routes under `/api/admin/domains`, all `require_platform_owner`:
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/admin/domains` | every route, with its owning org/app and target port |
|
||||||
|
| `POST` | `/admin/domains` | attach an extra host to an app |
|
||||||
|
| `DELETE` | `/admin/domains/{id}` | detach, with the host typed back as confirmation |
|
||||||
|
| `POST` | `/admin/domains/sync` | converge the router on the database |
|
||||||
|
| `GET` | `/admin/domains/suggested` | system routes the owner may enable (the MCP gateway) |
|
||||||
|
|
||||||
|
Attaching a host points it at whatever port the app already serves on (`demo`, then
|
||||||
|
`web`), so the owner never has to look a port up. Detaching requires the host typed
|
||||||
|
back — removing a route is what makes a live URL stop working, so it gets the same
|
||||||
|
typed confirmation as a production deploy — and is audited (`domain.detach`).
|
||||||
|
|
||||||
|
**Users cannot attach domains.** The member-facing `POST/DELETE
|
||||||
|
.../projects/{id}/preview/domains` routes in `projects.py` are now owner-only. 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.
|
||||||
|
|
||||||
|
### The host rule (`domain/platform/rules.py`)
|
||||||
|
|
||||||
|
`validate_extra_host(host, root_domain, allow_custom)` accepts exactly two shapes:
|
||||||
|
|
||||||
|
* a **single label under the managed root** (`shop.msa.artacloud.ir`) — the default; and
|
||||||
|
* a **full custom FQDN** (`shop.customer.example`), only when `ALLOW_CUSTOM_DOMAINS` is
|
||||||
|
on. A custom host needs its own DNS record and certificate, and accepting one that
|
||||||
|
will never resolve produces a domain that *looks* configured and is not.
|
||||||
|
|
||||||
|
A wildcard is refused in both shapes — `*.example.com` in a Caddy site block matches
|
||||||
|
every subdomain, including another tenant's. So is a 30-name list of protected platform
|
||||||
|
labels (`git`, `mcp`, `panel`, `api`, `www`, …): a tenant host that resolves to the
|
||||||
|
forge or the console is both a phishing surface and a routing conflict with a real
|
||||||
|
service. Hosts are normalised (lower-cased, trailing dot stripped, length-capped).
|
||||||
|
|
||||||
|
The rule is enforced **twice** — in the router and again inside the adapter — because a
|
||||||
|
rule checked in one place is a rule that will eventually be bypassed.
|
||||||
|
|
||||||
|
### 4.2 Re-sync is idempotent
|
||||||
|
|
||||||
|
Both adapters gained `routes_fingerprint()`, `list_all_routes()` and
|
||||||
|
`sync_routes(desired)`:
|
||||||
|
|
||||||
|
* `sync_routes` renders the desired state, compares it against what is on disk, and
|
||||||
|
**writes and reloads only when it differs**. Pressing the console's re-sync button
|
||||||
|
must not drop every in-flight connection on the host, including tenants' live demos.
|
||||||
|
* The mock adapter applies the same validation, so a test that passes against the mock
|
||||||
|
describes behaviour the real adapter also guarantees.
|
||||||
|
|
||||||
|
`sync_routes`/`routes_fingerprint` are **adapter-level extras**, not additions to
|
||||||
|
`DomainRouterPort` — that contract is frozen by the plan. The router declares a local
|
||||||
|
`SyncableRouter` protocol and reports "this router cannot re-sync" rather than crashing
|
||||||
|
against a router that does not implement them.
|
||||||
|
|
||||||
|
### 4.3 The MCP gateway as a suggested route
|
||||||
|
|
||||||
|
`GET /admin/domains/suggested` offers `mcp.<root>` → `MCP_GATEWAY_PORT`. It is
|
||||||
|
*suggested* rather than created automatically: it needs a DNS record and a certificate
|
||||||
|
before it can work, and creating it eagerly would produce a host that looks live and is
|
||||||
|
not. `enabled` reflects whether the route already exists.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `app/domain/platform/rules.py` | `validate_extra_host`, `PROTECTED_HOST_LABELS` |
|
||||||
|
| `app/infrastructure/adapters/caddy.py` | `routes_fingerprint`, `list_all_routes`, `sync_routes` |
|
||||||
|
| `app/infrastructure/adapters/mock.py` | the same three, with matching validation |
|
||||||
|
| `app/infrastructure/repositories/projects.py` | `PreviewDomainRepository.list_all()`, derived `url` |
|
||||||
|
| `app/interfaces/http/routers/admin_domains.py` | 5 owner-only routes |
|
||||||
|
| `app/interfaces/http/routers/projects.py` | preview-domain create/delete are owner-only |
|
||||||
|
| `tests/test_admin_domains.py` | new — 58 tests |
|
||||||
|
|
||||||
|
## Test output
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/api: 473 passed, 1 skipped (was 415; +58)
|
||||||
|
ruff check . -> All checks passed!
|
||||||
|
mypy app -> Success: no issues found in 132 source files
|
||||||
|
apps/web: 67 passed (unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deviations from the TODO
|
||||||
|
|
||||||
|
1. **`sync_routes` lives on the adapters, not the port.** The TODO says "the Caddy
|
||||||
|
adapter keeps validate → revert → reload. Add a `routes_fingerprint` check"; adding
|
||||||
|
either to `DomainRouterPort` would change a frozen contract, so a local protocol
|
||||||
|
covers it.
|
||||||
|
2. **Detach requires the host typed back** (the TODO says "confirm + audit"). A boolean
|
||||||
|
`confirm` would be one more thing to click through; typing the host is the same
|
||||||
|
pattern the production deploy uses, so operators meet it once and recognise it.
|
||||||
|
3. **`GET /admin/domains/suggested` is new.** The TODO asks for the MCP host to be
|
||||||
|
"recorded as a protected system route that the owner can enable from this page";
|
||||||
|
without a route to read the suggestion the page has nothing to render.
|
||||||
|
|
||||||
|
## Open questions / CHANGE REQUESTS
|
||||||
|
|
||||||
|
- **None.** `DomainRouterPort` and `platform/rules.py`'s existing exports are unchanged;
|
||||||
|
everything new is additive.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- A custom FQDN needs its DNS record and certificate to be arranged outside the platform
|
||||||
|
(Caddy will attempt ACME on reload, which fails for a host that does not resolve).
|
||||||
|
The console says so; it cannot do it for the operator.
|
||||||
|
- `sync_routes` is all-or-nothing: one invalid row in the ledger refuses the whole
|
||||||
|
re-sync rather than applying the valid subset. That is deliberate — a partial sync is
|
||||||
|
harder to reason about than a refusal — but it does mean one bad row blocks the button
|
||||||
|
until it is fixed.
|
||||||
Reference in New Issue
Block a user