Rename realms to domains; object-keyed origins/related config format

Finish the realm→domain terminology removal across source, tests, e2e
and docs. The stored config drops all lists: Config.domains is keyed by
rp-id, DomainConfig.origins/related are objects keyed by host (https://
omitted), values True or OriginEntry(auth_host=True). The default/primary
domain concept is gone; ordering is display-time. Tests and e2e updated
to the new API shapes (not run). Database re-migrated from the legacy
backup into the new format.
This commit is contained in:
2026-09-07 02:05:12 +00:00
parent 80d55679fb
commit f2e6f5784e
33 changed files with 627 additions and 651 deletions
+99 -109
View File
@@ -22,7 +22,7 @@ import pytest
import pytest_asyncio
import uuid7
from paskia import db, realms
from paskia import db, domains
from paskia.db import (
Credential,
Org,
@@ -1789,26 +1789,28 @@ class TestOrgAdminAuthExceptions:
assert response.status_code == 403
class TestRealms:
"""Tests for the realm management API (/auth/api/admin/realms/)."""
class TestDomains:
"""Tests for the domain management API (/auth/api/admin/domains/)."""
async def _set_auth_host(self, client, session_token, test_user, test_credential):
"""Configure an auth host on the localhost realm, as the admin UI would."""
"""Configure an auth host on the localhost domain, as the admin UI would."""
r = await client.patch(
"/auth/api/admin/realms/localhost",
"/auth/api/admin/domains/localhost",
json={
"rp_name": "",
"auth_host": "auth.localhost",
"origins": ["auth.localhost", "localhost"],
"origins": {
"auth.localhost": {"auth_host": True},
"localhost": True,
},
},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert r.status_code == 200, r.text
realm_cfg = db.data().config.find_realm("localhost")
assert realm_cfg.auth_host == "https://auth.localhost"
realm = realms.registry().get("localhost")
assert realm.own_auth_host == "auth.localhost"
assert realm.auth_site_url == "https://auth.localhost/"
domain_cfg = db.data().config.domains["localhost"]
assert domains.auth_host_url(domain_cfg) == "https://auth.localhost"
domain = domains.registry().get("localhost")
assert domain.own_auth_host == "auth.localhost"
assert domain.auth_site_url == "https://auth.localhost/"
# Session for requests coming from the auth host (sessions are host-bound)
_, token = create_test_session(
test_user.uuid, test_credential.uuid, host="auth.localhost"
@@ -1816,26 +1818,27 @@ class TestRealms:
return {**auth_headers(token), "Host": "auth.localhost"}
@pytest.mark.asyncio
async def test_list_realms(self, client: httpx.AsyncClient, session_token: str):
async def test_list_domains(self, client: httpx.AsyncClient, session_token: str):
r = await client.get(
"/auth/api/admin/realms/",
"/auth/api/admin/domains/",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert r.status_code == 200, r.text
data = r.json()
assert len(data) == 1
realm = data[0]
assert realm["rp_id"] == "localhost"
assert realm["is_default"] is True
assert realm["auth_host"] is None
assert realm["site_url"] == "http://localhost:4401"
domain = data[0]
assert domain["rp_id"] == "localhost"
assert domain["origins"] == {}
assert domain["related"] == {}
assert domain["effective_auth_host"] is None
assert domain["site_url"] == "http://localhost:4401"
@pytest.mark.asyncio
async def test_realms_require_master_admin(
async def test_domains_require_master_admin(
self, client: httpx.AsyncClient, regular_session_token: str
):
r = await client.get(
"/auth/api/admin/realms/",
"/auth/api/admin/domains/",
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
)
assert r.status_code in (401, 403)
@@ -1848,37 +1851,37 @@ class TestRealms:
test_user,
test_credential,
):
"""Removing auth_host must clear it from runtime realm config and URLs."""
"""Removing the auth host mark must clear it from runtime config and URLs."""
headers = await self._set_auth_host(
client, session_token, test_user, test_credential
)
# The dialog still lists the old auth host among origins, so it is sent back
r = await client.patch(
"/auth/api/admin/realms/localhost",
"/auth/api/admin/domains/localhost",
json={
"rp_name": "",
"auth_host": "",
"origins": ["auth.localhost", "localhost"],
"origins": {"auth.localhost": True, "localhost": True},
},
headers=headers,
)
assert r.status_code == 200, r.text
assert db.data().config.find_realm("localhost").auth_host is None
domain_cfg = db.data().config.domains["localhost"]
assert domains.auth_host_url(domain_cfg) is None
realm = realms.registry().get("localhost")
assert realm.own_auth_host is None
assert realm.ui_base_path == "/auth/"
# Site URL derivation is stateless: with the auth host removed, the
# first remaining origin becomes the site URL.
assert realm.auth_site_url == "https://auth.localhost/auth/"
domain = domains.registry().get("localhost")
assert domain.own_auth_host is None
assert domain.ui_base_path == "/auth/"
# Site URL derivation is stateless: with the auth host mark removed,
# the exact rp-id origin becomes the site URL.
assert domain.auth_site_url == "https://localhost/auth/"
# GET and settings reflect the cleared state
r = await client.get(
"/auth/api/admin/realms/",
"/auth/api/admin/domains/",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert r.json()[0]["auth_host"] is None
assert r.json()[0]["origins"] == {"auth.localhost": True, "localhost": True}
r = await client.get("/auth/api/settings")
assert r.json()["auth_host"] is None
assert r.json()["own_auth_host"] is None
@@ -1906,134 +1909,130 @@ class TestRealms:
)
r = await client.patch(
"/auth/api/admin/realms/localhost",
json={"rp_name": "", "auth_host": "", "origins": []},
"/auth/api/admin/domains/localhost",
json={"rp_name": "", "origins": {}},
headers=headers,
)
assert r.status_code == 200, r.text
realm = realms.registry().get("localhost")
assert realm.own_auth_host is None
assert realm.ui_base_path == "/auth/"
assert "auth.localhost" not in realm.site_url
assert "auth.localhost" not in realm.auth_site_url
domain = domains.registry().get("localhost")
assert domain.own_auth_host is None
assert domain.ui_base_path == "/auth/"
assert "auth.localhost" not in domain.site_url
assert "auth.localhost" not in domain.auth_site_url
@pytest.mark.asyncio
async def test_create_and_delete_realm(
async def test_create_and_delete_domain(
self, client: httpx.AsyncClient, session_token: str
):
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
r = await client.post(
"/auth/api/admin/realms/",
"/auth/api/admin/domains/",
json={
"rp_id": "example.com",
"rp_name": "Example",
"origins": ["https://app.example.com"],
"related_origins": ["https://unrelated-site.com"],
"origins": {"app.example.com": True},
"related": {"unrelated-site.com": True},
},
headers=headers,
)
assert r.status_code == 200, r.text
r = await client.get("/auth/api/admin/realms/", headers=headers)
realms_list = {realm["rp_id"]: realm for realm in r.json()}
assert set(realms_list) == {"localhost", "example.com"}
created = realms_list["example.com"]
r = await client.get("/auth/api/admin/domains/", headers=headers)
domains_list = {domain["rp_id"]: domain for domain in r.json()}
assert set(domains_list) == {"localhost", "example.com"}
created = domains_list["example.com"]
assert created["rp_name"] == "Example"
assert created["is_default"] is False
assert created["related_origins"] == ["https://unrelated-site.com"]
assert created["related"] == {"unrelated-site.com": True}
# OIDC provider seeded for the new realm
# OIDC provider seeded for the new domain
assert db.data().oidc_for("example.com") is not None
r = await client.delete("/auth/api/admin/realms/example.com", headers=headers)
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
assert r.status_code == 200, r.text
assert db.data().config.find_realm("example.com") is None
assert realms.registry().get("example.com") is None
assert "example.com" not in db.data().config.domains
assert domains.registry().get("example.com") is None
@pytest.mark.asyncio
async def test_create_realm_validation(
async def test_create_domain_validation(
self, client: httpx.AsyncClient, session_token: str
):
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
# rp_id is required
r = await client.post("/auth/api/admin/realms/", json={}, headers=headers)
r = await client.post("/auth/api/admin/domains/", json={}, headers=headers)
assert r.status_code == 400
# Duplicate rp-id
r = await client.post(
"/auth/api/admin/realms/", json={"rp_id": "localhost"}, headers=headers
"/auth/api/admin/domains/", json={"rp_id": "localhost"}, headers=headers
)
assert r.status_code == 400
# Invalid rp-id
r = await client.post(
"/auth/api/admin/realms/", json={"rp_id": "not a domain!"}, headers=headers
"/auth/api/admin/domains/", json={"rp_id": "not a domain!"}, headers=headers
)
assert r.status_code == 400
# auth-host must be a subdomain of the rp-id
# An auth host must be within the rp-id domain
r = await client.post(
"/auth/api/admin/realms/",
json={"rp_id": "example.com", "auth_host": "auth.other.com"},
"/auth/api/admin/domains/",
json={
"rp_id": "example.com",
"origins": {"auth.other.com": {"auth_host": True}},
},
headers=headers,
)
assert r.status_code == 400
# Related origin host may not collide across realms
# Related origin host may not collide across domains
r = await client.post(
"/auth/api/admin/realms/",
json={
"rp_id": "example.com",
"related_origins": ["https://shared-app.com"],
},
"/auth/api/admin/domains/",
json={"rp_id": "example.com", "related": {"shared-app.com": True}},
headers=headers,
)
assert r.status_code == 200
r = await client.post(
"/auth/api/admin/realms/",
json={"rp_id": "other.com", "related_origins": ["https://shared-app.com"]},
"/auth/api/admin/domains/",
json={"rp_id": "other.com", "related": {"shared-app.com": True}},
headers=headers,
)
assert r.status_code == 400
# Cross-domain entries are rejected from the in-domain origins list
r = await client.post(
"/auth/api/admin/realms/",
json={"rp_id": "another.com", "origins": ["https://elsewhere.com"]},
"/auth/api/admin/domains/",
json={"rp_id": "another.com", "origins": {"elsewhere.com": True}},
headers=headers,
)
assert r.status_code == 400
# In-domain entries are rejected from the related origins list
r = await client.post(
"/auth/api/admin/realms/",
json={
"rp_id": "another.com",
"related_origins": ["https://app.another.com"],
},
"/auth/api/admin/domains/",
json={"rp_id": "another.com", "related": {"app.another.com": True}},
headers=headers,
)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_delete_realm_guards(
async def test_delete_domain_guards(
self, client: httpx.AsyncClient, session_token: str, test_credential
):
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
# Cannot delete the last realm
r = await client.delete("/auth/api/admin/realms/localhost", headers=headers)
# Cannot delete the last domain
r = await client.delete("/auth/api/admin/domains/localhost", headers=headers)
assert r.status_code == 400
# Unknown realm
r = await client.delete("/auth/api/admin/realms/nope.com", headers=headers)
# Unknown domain
r = await client.delete("/auth/api/admin/domains/nope.com", headers=headers)
assert r.status_code == 400
# A realm with credentials still registered under it cannot be deleted
# A domain with credentials still registered under it cannot be deleted
r = await client.post(
"/auth/api/admin/realms/", json={"rp_id": "example.com"}, headers=headers
"/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers
)
assert r.status_code == 200
cred = Credential.create(
@@ -2045,11 +2044,11 @@ class TestRealms:
rp_id="example.com",
)
create_credential(cred)
r = await client.delete("/auth/api/admin/realms/example.com", headers=headers)
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_update_realm_refuses_self_lockout(
async def test_update_domain_refuses_self_lockout(
self, client: httpx.AsyncClient, session_token: str
):
"""An allow-list excluding the admin's current host is refused."""
@@ -2057,12 +2056,8 @@ class TestRealms:
# Allow-list without the current host and no auth host → lockout
r = await client.patch(
"/auth/api/admin/realms/localhost",
json={
"rp_name": "",
"auth_host": "",
"origins": ["https://auth.localhost"],
},
"/auth/api/admin/domains/localhost",
json={"rp_name": "", "origins": {"auth.localhost": True}},
headers=headers,
)
assert r.status_code == 400
@@ -2070,12 +2065,8 @@ class TestRealms:
# Allow-list including the current host is fine
r = await client.patch(
"/auth/api/admin/realms/localhost",
json={
"rp_name": "",
"auth_host": "",
"origins": ["https://localhost:4401"],
},
"/auth/api/admin/domains/localhost",
json={"rp_name": "", "origins": {"localhost:4401": True}},
headers=headers,
)
assert r.status_code == 200, r.text
@@ -2084,31 +2075,30 @@ class TestRealms:
# host is set: ceremonies move there (and it is always allowed).
# Done last: with an auth host set, the API here routes differently.
r = await client.patch(
"/auth/api/admin/realms/localhost",
"/auth/api/admin/domains/localhost",
json={
"rp_name": "",
"auth_host": "auth.localhost",
"origins": ["https://auth.localhost"],
"origins": {"auth.localhost": {"auth_host": True}},
},
headers=headers,
)
assert r.status_code == 200, r.text
@pytest.mark.asyncio
async def test_delete_current_realm_refused(
async def test_delete_current_domain_refused(
self, client: httpx.AsyncClient, session_token: str
):
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
r = await client.post(
"/auth/api/admin/realms/", json={"rp_id": "example.com"}, headers=headers
"/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers
)
assert r.status_code == 200
# Deleting the realm in use is refused even if it has no credentials
r = await client.delete("/auth/api/admin/realms/localhost", headers=headers)
# Deleting the domain in use is refused even if it has no credentials
r = await client.delete("/auth/api/admin/domains/localhost", headers=headers)
assert r.status_code == 400
assert "currently using" in r.text
# Deleting another realm while authenticated here is fine
r = await client.delete("/auth/api/admin/realms/example.com", headers=headers)
# Deleting another domain while authenticated here is fine
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
assert r.status_code == 200, r.text
@pytest.mark.asyncio
@@ -2119,12 +2109,12 @@ class TestRealms:
test_user,
test_credential,
):
"""A realm without its own auth host uses the shared one in settings."""
"""A domain without its own auth host uses the shared one in settings."""
headers = await self._set_auth_host(
client, session_token, test_user, test_credential
)
r = await client.post(
"/auth/api/admin/realms/", json={"rp_id": "example.com"}, headers=headers
"/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers
)
assert r.status_code == 200