From 4dedf33da070a9a4e022d1617c68a4a829fb4d73 Mon Sep 17 00:00:00 2001 From: Babissimo Date: Mon, 14 Sep 2026 15:32:44 +0100 Subject: [PATCH 1/3] Serve the ref-to-node_id mapping on an admin-only route The publication boundary (D16) leaves the admin pages with only half an identity: they are built on /api/radar/nodes and /api/radar/analytics, both keyed on node_ref and carrying no node_id at all. An admin needs the private id to say which box an operator is looking at, to reach the node's own site, and to join the node_id-keyed admin routes beside it. ref_to_id_map is the inverse of the boundary, so it is served from exactly one place, gated on require_admin. It widens the registry with ids the caller already holds, resolved through owner_identity, so a mirrored node and a synthetic one appear under the handle they publish as; a registry row wins over a mirrored ref, as it does on the way out. Worth knowing where this lands: prod and staging both run AUTH_ALLOW_ANONYMOUS_ADMIN with no OAuth, so require_admin admits every caller there and this mapping is effectively public until ClickUp 86cb1emcx closes that door. The gate is the right one; the environment is what makes it a no-op, and /api/admin/node-contacts already hands out node_ids the same way. Co-Authored-By: Claude Opus 5 --- backend/routes/admin.py | 23 ++++++++++++- backend/services/node_refs.py | 21 ++++++++++++ backend/tests/test_admin_routes.py | 53 ++++++++++++++++++++++++++++++ backend/tests/test_node_refs.py | 47 ++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 70b55915..8fb13166 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -46,7 +46,7 @@ user_to_dict, ) from services import publication -from services.node_refs import id_for_ref, public_identity, public_name +from services.node_refs import id_for_ref, public_identity, public_name, ref_to_id_map logger = logging.getLogger(__name__) @@ -352,6 +352,27 @@ async def admin_clear_node_location_privacy(node_id: str, admin=Depends(require_ } +@router.get("/node-refs") +async def admin_list_node_refs(_admin=Depends(require_admin)): + """Return {node_ref: node_id} for the whole fleet. + + The one route that serves the mapping publication exists to withhold (D16), + which is why it is gated on require_admin rather than on a logged-in caller + the way the leaderboard is. The admin pages are built on the public, + ref-keyed feeds, so this is what lets them name a node to an operator, join + the node_id-keyed admin routes beside it, and link to the node's own site — + which is named after the node_id, not the ref. + + While a deployment sets AUTH_ALLOW_ANONYMOUS_ADMIN with no OAuth configured, + require_admin admits every caller and this mapping is public there. Closing + that door is ClickUp 86cb1emcx; until it closes, treat any environment with + the bypass on as publishing the whole boundary, not just this route. + """ + with state.connected_nodes_lock: + connected = list(state.connected_nodes) + return ref_to_id_map(connected) + + @router.get("/node-contacts") async def admin_list_node_contacts( session: AsyncSession = Depends(get_async_session), diff --git a/backend/services/node_refs.py b/backend/services/node_refs.py index a9e1cdb4..c263a028 100644 --- a/backend/services/node_refs.py +++ b/backend/services/node_refs.py @@ -143,6 +143,27 @@ def id_for_identity(identity: str | None) -> str | None: return id_for_ref(identity) +def ref_to_id_map(node_ids: Iterable[str] = ()) -> dict[str, str]: + """Every published handle and the node behind it: {node_ref: node_id}. + + The inverse of the boundary, so only a caller already entitled to both + identifiers may be handed it — today that is routes/admin.py's `node-refs`, + gated on require_admin. Everything else resolves one node at a time. + + `node_ids` widens the registry with ids the caller holds, resolved through + `owner_identity` so a mirrored node and a synthetic one appear under the + same handle they publish as. A registry row wins over a mirrored ref, as it + does on the way out; a node with no handle at all is absent rather than + named under its own id. + """ + _refresh() + mapping = dict(_reverse) + for node_id in node_ids: + if ref := owner_identity(node_id): + mapping.setdefault(ref, node_id) + return mapping + + def _names_a_node(value: str, known_ids: Iterable[str]) -> bool: """Whether a string is the private id of a node. diff --git a/backend/tests/test_admin_routes.py b/backend/tests/test_admin_routes.py index 45f8bb54..a8261187 100644 --- a/backend/tests/test_admin_routes.py +++ b/backend/tests/test_admin_routes.py @@ -658,3 +658,56 @@ def test_both_routes_are_gated_on_require_admin(self): assert "/api/admin/node-contacts" in gated assert "/api/admin/nodes/{node_id}/contact" in gated + + +class TestNodeRefs: + """The one route that hands back the mapping publication withholds.""" + + @staticmethod + def _register(node_id, node_ref): + import asyncio + + from core.nodes import Node + from core.users import async_session_maker + + async def _go(): + async with async_session_maker() as session: + session.add(Node(node_id=node_id, node_ref=node_ref)) + await session.commit() + + asyncio.run(_go()) + asyncio.set_event_loop(asyncio.new_event_loop()) + + def teardown_method(self): + with state.connected_nodes_lock: + state.connected_nodes.clear() + + def test_it_names_the_node_behind_a_ref(self, client): + self._register("ret1a2b3c4d", "nde1a2b3c4d00") + + body = client.get("/api/admin/node-refs").json() + + assert body["nde1a2b3c4d00"] == "ret1a2b3c4d" + + def test_it_covers_a_connected_node_the_registry_cannot_answer_for(self, client): + """A mirrored node carries its ref in the fleet snapshot, not a row.""" + with state.connected_nodes_lock: + state.connected_nodes["ret0badcafe"] = {"status": "active", "node_ref": "ndemirrored001"} + + body = client.get("/api/admin/node-refs").json() + + assert body["ndemirrored001"] == "ret0badcafe" + + def test_it_is_gated_on_require_admin(self): + """The suite runs with AUTH_ALLOW_ANONYMOUS_ADMIN=1, so the gate is + asserted where it is declared rather than by a refused request.""" + from core.users import require_admin + + gated = { + route.path + for route in app.routes + if getattr(route, "dependant", None) + and any(dep.call is require_admin for dep in route.dependant.dependencies) + } + + assert "/api/admin/node-refs" in gated diff --git a/backend/tests/test_node_refs.py b/backend/tests/test_node_refs.py index 5f27f327..1d645eb6 100644 --- a/backend/tests/test_node_refs.py +++ b/backend/tests/test_node_refs.py @@ -360,3 +360,50 @@ def test_a_mirrored_node_reaches_the_published_feed(self, seed): self._connected(node_ref="ndemirrored001") out = node_refs.substitute_identities({"aircraft": [{"hex": "abc123", "node_id": _MIRRORED}]}) assert out["aircraft"] == [{"hex": "abc123", "node_ref": "ndemirrored001"}] + + +class TestRefToIdMap: + """The inverse of the boundary, for the admin routes allowed to cross it.""" + + def _connected(self, node_id, **entry): + from core import state + + with state.connected_nodes_lock: + state.connected_nodes[node_id] = {"is_synthetic": False, "status": "active", **entry} + + def teardown_method(self): + from core import state + + with state.connected_nodes_lock: + state.connected_nodes.clear() + + def test_it_names_the_node_behind_every_registered_ref(self, seed): + seed(ret1a2b3c4d="nde1a2b3c4d00", ret9f8e7d6c="nde9f8e7d6c00") + assert node_refs.ref_to_id_map() == { + "nde1a2b3c4d00": "ret1a2b3c4d", + "nde9f8e7d6c00": "ret9f8e7d6c", + } + + def test_a_mirrored_node_resolves_through_the_ids_it_is_given(self, seed): + """It has no row here, so only a caller holding the id can place it.""" + seed(ret1a2b3c4d="nde1a2b3c4d00") + self._connected(_MIRRORED, node_ref="ndemirrored001") + assert node_refs.ref_to_id_map([_MIRRORED])["ndemirrored001"] == _MIRRORED + + def test_the_registry_wins_over_a_mirrored_ref(self, seed): + seed(**{_MIRRORED: "ndelocalrow001"}) + self._connected(_MIRRORED, node_ref="ndemirrored001") + mapping = node_refs.ref_to_id_map([_MIRRORED]) + assert mapping["ndelocalrow001"] == _MIRRORED + assert "ndemirrored001" not in mapping + + def test_a_synthetic_node_maps_to_itself(self, seed): + """It publishes under its own id, so that id is both halves.""" + seed(ret1a2b3c4d="nde1a2b3c4d00") + self._connected("synth-node-1", is_synthetic=True) + assert node_refs.ref_to_id_map(["synth-node-1"])["synth-node-1"] == "synth-node-1" + + def test_a_node_with_no_handle_at_all_is_left_out(self, seed): + seed(ret1a2b3c4d="nde1a2b3c4d00") + self._connected(_MIRRORED) + assert node_refs.ref_to_id_map([_MIRRORED]) == {"nde1a2b3c4d00": "ret1a2b3c4d"} From a1d898da7ac466c3a87a22d7a7b7ce041e786828 Mon Sep 17 00:00:00 2001 From: Babissimo Date: Mon, 14 Sep 2026 15:32:57 +0100 Subject: [PATCH 2/3] Show both identifiers on the admin pages, and link the private one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin pages showed the node_ref alone under a column headed "Node ID", and handed that ref to RetnodeLink, which builds