From 82cea1e690d2cd92baf899e845563c3c05708258 Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Wed, 14 Oct 2026 00:57:08 +0330 Subject: [PATCH] feat(delivery): owner-only domains + idempotent route re-sync (v2 phase A3-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/app/domain/platform/rules.py | 98 +++++ apps/api/app/infrastructure/adapters/caddy.py | 75 ++++ apps/api/app/infrastructure/adapters/mock.py | 45 +++ .../infrastructure/repositories/projects.py | 17 + .../interfaces/http/routers/admin_domains.py | 324 ++++++++++++++- .../app/interfaces/http/routers/projects.py | 18 +- apps/api/tests/test_admin_domains.py | 369 ++++++++++++++++++ docs/plan/reports/agent-3-phase-4.md | 117 ++++++ 8 files changed, 1059 insertions(+), 4 deletions(-) create mode 100644 apps/api/tests/test_admin_domains.py create mode 100644 docs/plan/reports/agent-3-phase-4.md diff --git a/apps/api/app/domain/platform/rules.py b/apps/api/app/domain/platform/rules.py index 3e4b5ff..8836a12 100644 --- a/apps/api/app/domain/platform/rules.py +++ b/apps/api/app/domain/platform/rules.py @@ -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}(? 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: diff --git a/apps/api/app/infrastructure/adapters/caddy.py b/apps/api/app/infrastructure/adapters/caddy.py index 7aae3a5..4f265d9 100644 --- a/apps/api/app/infrastructure/adapters/caddy.py +++ b/apps/api/app/infrastructure/adapters/caddy.py @@ -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: diff --git a/apps/api/app/infrastructure/adapters/mock.py b/apps/api/app/infrastructure/adapters/mock.py index 165060c..a00784a 100644 --- a/apps/api/app/infrastructure/adapters/mock.py +++ b/apps/api/app/infrastructure/adapters/mock.py @@ -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 diff --git a/apps/api/app/infrastructure/repositories/projects.py b/apps/api/app/infrastructure/repositories/projects.py index cf14b0b..5c2f718 100644 --- a/apps/api/app/infrastructure/repositories/projects.py +++ b/apps/api/app/infrastructure/repositories/projects.py @@ -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}", } diff --git a/apps/api/app/interfaces/http/routers/admin_domains.py b/apps/api/app/interfaces/http/routers/admin_domains.py index ceb7497..0a115df 100644 --- a/apps/api/app/interfaces/http/routers/admin_domains.py +++ b/apps/api/app/interfaces/http/routers/admin_domains.py @@ -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 `.` 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"]) diff --git a/apps/api/app/interfaces/http/routers/projects.py b/apps/api/app/interfaces/http/routers/projects.py index 7ecbbee..6e44e44 100644 --- a/apps/api/app/interfaces/http/routers/projects.py +++ b/apps/api/app/interfaces/http/routers/projects.py @@ -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) diff --git a/apps/api/tests/test_admin_domains.py b/apps/api/tests/test_admin_domains.py new file mode 100644 index 0000000..e4565b6 --- /dev/null +++ b/apps/api/tests/test_admin_domains.py @@ -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 diff --git a/docs/plan/reports/agent-3-phase-4.md b/docs/plan/reports/agent-3-phase-4.md new file mode 100644 index 0000000..817217e --- /dev/null +++ b/docs/plan/reports/agent-3-phase-4.md @@ -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.` → `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.