diff --git a/docs/routes.md b/docs/routes.md index 0602f6c..5c32276 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -46,7 +46,7 @@ Routes carry the same role-based visibility levels as channels — `community`, Both operators and admins can create routes. A user may never scope a route above their own role (e.g. an operator cannot create an `admin`-visibility route) — this is enforced on the write endpoints and prevents a user from creating a route they could then never see or modify. -**Ownership-based editing:** Each route records the user who created it (`created_by`). Operators can edit and delete only the routes they created. Admins can edit and delete any route, and take ownership when they edit a route they did not create (the `created_by` field updates to the admin's user ID). Routes created before ownership tracking was introduced have a `NULL` `created_by` and are admin-only. The creator's display name is shown on each route card when available. Attempting to modify a route above the caller's visibility tier returns `404`; modifying a visible route the caller does not own returns `403`. +**Ownership-based editing:** Each route records the user who created it (`created_by`). Operators can edit and delete only the routes they created. Admins can edit and delete any route; they claim ownership of legacy (unowned) routes — those with a `NULL` `created_by` — when they edit them, but do not displace an existing creator. Routes created before ownership tracking was introduced have a `NULL` `created_by` and are admin-only. The creator's display name is shown on each route card when available. Attempting to modify a route above the caller's visibility tier returns `404`; modifying a visible route the caller does not own returns `403`. ## Defining routes diff --git a/src/meshcore_hub/api/routes/routes.py b/src/meshcore_hub/api/routes/routes.py index 9d3c8f5..a64d0d6 100644 --- a/src/meshcore_hub/api/routes/routes.py +++ b/src/meshcore_hub/api/routes/routes.py @@ -647,7 +647,8 @@ def update_route( """Update a route (operator or admin). Operators may only modify routes they created; admins can modify any - route and take ownership on edit. + route. Admins claim ownership of legacy (unowned) routes on edit but + do not displace an existing creator. """ user_id, _ = caller route = session.execute( @@ -657,10 +658,10 @@ def update_route( raise HTTPException(status_code=404, detail="Route not found") _assert_route_modifiable(request, route) - # Admin edits transfer ownership to the admin - if resolve_user_role(request) == "admin" and route.created_by != user_id: + # Admin claims ownership of legacy (unowned) routes on edit + if resolve_user_role(request) == "admin" and route.created_by is None: route.created_by = user_id - logger.info("Admin %s took ownership of route %s", user_id, route.id) + logger.info("Admin %s claimed ownership of legacy route %s", user_id, route.id) if body.from_label is not None or body.to_label is not None: new_from = body.from_label if body.from_label is not None else route.from_label diff --git a/tests/test_api/test_routes.py b/tests/test_api/test_routes.py index 90d382a..86dae7f 100644 --- a/tests/test_api/test_routes.py +++ b/tests/test_api/test_routes.py @@ -1324,7 +1324,8 @@ class TestRouteOperatorPermissions: Ownership model (PR #337): - Operators can edit/delete only routes they created. - - Admins can edit/delete any route and take ownership on edit. + - Admins can edit/delete any route; they claim ownership of legacy + (unowned) routes on edit but do not displace an existing creator. - Routes with NULL ``created_by`` (legacy) are admin-only. """ @@ -1601,12 +1602,14 @@ class TestRouteOperatorPermissions: assert resp.status_code == 200 assert resp.json()["created_by"] == "test-operator" - # ── Admin ownership transfer ────────────────────────────────────── + # ── Admin ownership semantics ───────────────────────────────────── - def test_admin_edit_transfers_ownership(self, client_no_auth, api_db_session): - """Admin editing a route takes ownership (created_by changes).""" + def test_admin_edit_preserves_existing_ownership( + self, client_no_auth, api_db_session + ): + """Admin editing an operator-created route does NOT take ownership.""" route = Route( - from_label="Transfer", + from_label="Preserve", to_label="End", visibility="operator", created_by="test-operator", @@ -1616,19 +1619,20 @@ class TestRouteOperatorPermissions: resp = client_no_auth.put( f"/api/v1/routes/{route.id}", - json={"description": "admin took over"}, + json={"description": "admin tweaked it"}, headers=ADMIN_HEADERS, ) assert resp.status_code == 200 - assert resp.json()["created_by"] == "test-admin" + # Ownership stays with the original creator + assert resp.json()["created_by"] == "test-operator" - # Original operator can no longer edit + # Original operator can still edit resp2 = client_no_auth.put( f"/api/v1/routes/{route.id}", - json={"description": "try again"}, + json={"description": "operator still owns it"}, headers=OPERATOR_HEADERS, ) - assert resp2.status_code == 403 + assert resp2.status_code == 200 def test_admin_edit_adopts_null_created_by(self, client_no_auth, api_db_session): """Admin editing a legacy route (NULL created_by) takes ownership."""