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:
@@ -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
|
||||
Reference in New Issue
Block a user