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