mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-10 19:03:03 +02:00
fix(routes): preserve existing ownership on admin edit
Admins editing an operator-created route no longer steal ownership. Ownership transfer now happens only for legacy (NULL created_by) routes. This ensures operators retain edit access to their routes after an admin makes a small tweak.
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user