diff --git a/alembic/versions/20260724_1000_da69304d8106_add_routes_created_by.py b/alembic/versions/20260724_1000_da69304d8106_add_routes_created_by.py
new file mode 100644
index 0000000..24a346f
--- /dev/null
+++ b/alembic/versions/20260724_1000_da69304d8106_add_routes_created_by.py
@@ -0,0 +1,36 @@
+"""add routes.created_by
+
+Revision ID: da69304d8106
+Revises: d307ee761a34
+Create Date: 2026-07-24 10:00:00.000000+00:00
+
+Adds a nullable ``created_by`` column to ``routes``, storing the OIDC
+subject identifier (``user_id``) of the operator or admin who created
+the route. This enables ownership-based write permissions: operators
+can only modify routes they created, while admins can modify any route
+and take ownership on edit.
+
+Existing routes get ``NULL`` (admin-only modification), preserving
+backwards compatibility with no data backfill.
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "da69304d8106"
+down_revision: Union[str, None] = "d307ee761a34"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("routes", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("created_by", sa.String(255), nullable=True))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("routes", schema=None) as batch_op:
+ batch_op.drop_column("created_by")
diff --git a/docs/auth.md b/docs/auth.md
index ec976e5..bb0af3c 100644
--- a/docs/auth.md
+++ b/docs/auth.md
@@ -21,7 +21,7 @@ User roles are read from the OIDC token's `roles` claim (configurable via `OIDC_
| Role | Config Variable | Default | Description |
|------|----------------|---------|-------------|
| Admin | `OIDC_ROLE_ADMIN` | `admin` | Full write access to all API endpoints through the proxy |
-| Operator | `OIDC_ROLE_OPERATOR` | `operator` | Manage nodes, node tags, adoptions, and routes (create/edit/delete, scoped to the operator visibility tier) |
+| Operator | `OIDC_ROLE_OPERATOR` | `operator` | Manage nodes, node tags, adoptions, and routes (create; edit/delete only routes they created) |
| Member | `OIDC_ROLE_MEMBER` | `member` | Read-only access (no endpoint assignments) |
The role names are configurable to match your IdP's role naming convention. For example, if your IdP uses `superuser` instead of `admin`, set `OIDC_ROLE_ADMIN=superuser`.
diff --git a/docs/routes.md b/docs/routes.md
index b702851..5c32276 100644
--- a/docs/routes.md
+++ b/docs/routes.md
@@ -44,7 +44,9 @@ The collector runs a background thread that re-evaluates every enabled route on
Routes carry the same role-based visibility levels as channels — `community`, `member`, `operator`, `admin`. A user only sees routes whose visibility is at or below their role's maximum level. Seeded routes default to `community` (visible to everyone); set a higher level to restrict a route to operators/admins only. Visibility is enforced on both the list and detail endpoints, so a hidden route's existence is not leaked.
-Both operators and admins can create, edit, and delete 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. Operators can only edit/delete routes whose visibility is at or below the operator tier; attempting to modify a higher-visibility route returns `404`.
+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; 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
@@ -53,4 +55,4 @@ Routes are keyed by their `from`/`to` endpoint labels and upserted by that pair.
- **Seed YAML** — add a `routes.yaml` to your `SEED_HOME` and run the seed process. See [seeding.md → Routes](seeding.md#routes) for the format and rules (path nodes must already exist in the database; the `(from, to)` pair must be unique).
- **API** — `POST /api/v1/routes` (operator or admin) creates a route, with a `/preview` endpoint that dry-runs matching against an unsaved configuration so you can tune thresholds before committing. See `SCHEMAS.md` for the request/response shapes.
-The `/routes` page renders the live status card, the per-day history strip, recent matching transmissions (with observer attribution), and — for operators and admins — inline edit/delete controls.
+The `/routes` page renders the live status card, the per-day history strip, recent matching transmissions (with observer attribution), the route owner's name, and inline edit/delete controls gated per-route by ownership (operators see controls only on their own routes; admins see them on all routes).
diff --git a/e2e/tests/routes-operator.spec.ts b/e2e/tests/routes-operator.spec.ts
index 23f31ee..acdc571 100644
--- a/e2e/tests/routes-operator.spec.ts
+++ b/e2e/tests/routes-operator.spec.ts
@@ -12,13 +12,16 @@ test.describe.serial("routes (operator)", () => {
await page.goto("/routes");
// Operators see the seeded community route and the add button.
- await expect(
- page.locator(
- '[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]',
- ),
- ).toBeVisible();
+ const seededCard = page.locator(
+ '[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]',
+ );
+ await expect(seededCard).toBeVisible();
await expect(page.getByTestId("add-route")).toBeVisible();
+ // The seeded route has NULL created_by — operator must NOT see edit/delete.
+ await expect(seededCard.getByTestId("edit-route")).toHaveCount(0);
+ await expect(seededCard.getByTestId("delete-route")).toHaveCount(0);
+
// The visibility dropdown must NOT offer the admin tier to an operator.
await page.getByTestId("add-route").click();
const modal = page.locator('[data-testid="route-modal"]');
@@ -45,7 +48,7 @@ test.describe.serial("routes (operator)", () => {
);
await expect(card).toBeVisible();
- // Operator can edit the route they created.
+ // Operator owns the route they just created — edit/delete should be present.
await card.getByTestId("edit-route").click();
await expect(modal).toBeVisible();
await expect(page.getByTestId("route-from")).toHaveValue("Op From");
diff --git a/e2e/tests/routes.spec.ts b/e2e/tests/routes.spec.ts
index cfaa56a..e64c9d7 100644
--- a/e2e/tests/routes.spec.ts
+++ b/e2e/tests/routes.spec.ts
@@ -11,9 +11,14 @@ test.describe.serial("routes (admin)", () => {
}) => {
await page.goto("/routes");
- await expect(
- page.locator('[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]'),
- ).toBeVisible();
+ const seededCard = page.locator(
+ '[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]',
+ );
+ await expect(seededCard).toBeVisible();
+
+ // Admin sees edit/delete on all routes, including legacy (NULL created_by) ones.
+ await expect(seededCard.getByTestId("edit-route")).toBeVisible();
+ await expect(seededCard.getByTestId("delete-route")).toBeVisible();
await page.getByTestId("add-route").click();
const modal = page.locator('[data-testid="route-modal"]');
diff --git a/src/meshcore_hub/api/routes/routes.py b/src/meshcore_hub/api/routes/routes.py
index 8066163..a64d0d6 100644
--- a/src/meshcore_hub/api/routes/routes.py
+++ b/src/meshcore_hub/api/routes/routes.py
@@ -1,12 +1,13 @@
"""Route health monitoring API routes."""
+import logging
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from sqlalchemy import select
-from meshcore_hub.api.auth import RequireOperatorOrAdmin, RequireRead
+from meshcore_hub.api.auth import RequireOperatorOrAdmin, RequireRead, X_USER_ID_HEADER
from meshcore_hub.api.cache import cached, sorted_query_string
from meshcore_hub.api.cache_invalidation import invalidate_routes
from meshcore_hub.api.channel_visibility import (
@@ -15,6 +16,7 @@ from meshcore_hub.api.channel_visibility import (
resolve_user_role,
)
from meshcore_hub.api.dependencies import DbSession
+from meshcore_hub.api.profile_utils import get_or_create_profile
from meshcore_hub.collector.routes import (
compute_persisted_quality_avg,
derive_expected_hash,
@@ -34,6 +36,7 @@ from meshcore_hub.common.models.route_node import RouteNode
from meshcore_hub.common.models.route_observer import RouteObserver
from meshcore_hub.common.models.route_recent_match import RouteRecentMatch
from meshcore_hub.common.models.route_result import RouteResult
+from meshcore_hub.common.models.user_profile import UserProfile
from meshcore_hub.common.schemas.routes import (
ContributingObserver,
RecentMatchPath,
@@ -44,6 +47,7 @@ from meshcore_hub.common.schemas.routes import (
RouteList,
RouteNodeRead,
RouteObserverRead,
+ RouteOwner,
RoutePreviewRequest,
RoutePreviewResponse,
RouteRead,
@@ -52,6 +56,7 @@ from meshcore_hub.common.schemas.routes import (
)
router = APIRouter()
+logger = logging.getLogger(__name__)
# Sentinel distinguishing "use the precomputed value" from "explicitly None"
# (the latter is what create_route passes to preserve the "brand-new route
@@ -88,15 +93,28 @@ def _assert_visibility_within_role(request: Request, visibility: str) -> None:
def _assert_route_modifiable(request: Request, route: Route) -> None:
- """Reject modifying a route above the caller's visibility tier.
+ """Reject modifying a route the caller is not allowed to touch.
- Returns 404 (mirroring the GET detail behaviour) so the existence of a
- higher-visibility route is not leaked to lower-privileged callers.
+ Layer 1 — visibility: a route above the caller's tier yields **404**
+ so its existence is not leaked (mirrors GET detail behaviour).
+
+ Layer 2 — ownership: operators may only modify routes *they* created.
+ A visible-but-unowned route yields **403** (the caller can see it in
+ the list, so a transparent rejection is better UX). ``created_by``
+ is ``None`` for legacy routes (pre-ownership-tracking) and is treated
+ as admin-only. Admins bypass the ownership check entirely.
"""
if VISIBILITY_LEVELS.get(route.visibility, 0) > _caller_max_visibility_level(
request
):
raise HTTPException(status_code=404, detail="Route not found")
+ if resolve_user_role(request) != "admin":
+ caller_id = request.headers.get(X_USER_ID_HEADER, "")
+ if route.created_by is None or route.created_by != caller_id:
+ raise HTTPException(
+ status_code=403,
+ detail="You can only modify routes you created",
+ )
def _route_node_to_read(rn: RouteNode) -> RouteNodeRead:
@@ -130,13 +148,58 @@ def _result_to_summary(result: RouteResult | None) -> RouteResultSummary | None:
)
-def _route_to_read(route: Route, *, quality_avg: Any = _UNSET) -> RouteRead:
+def _profile_to_owner(profile: UserProfile) -> RouteOwner:
+ """Convert a UserProfile to the lightweight RouteOwner display schema."""
+ return RouteOwner(
+ user_id=profile.user_id,
+ name=profile.name,
+ callsign=profile.callsign,
+ profile_id=profile.id,
+ )
+
+
+def _resolve_owner(session: DbSession, created_by: str | None) -> UserProfile | None:
+ """Resolve a single ``created_by`` user_id to a UserProfile."""
+ if not created_by:
+ return None
+ return session.execute(
+ select(UserProfile).where(UserProfile.user_id == created_by)
+ ).scalar_one_or_none()
+
+
+def _resolve_owners_batch(
+ session: DbSession, routes: list[Route]
+) -> dict[str, UserProfile]:
+ """Batch-resolve creator profiles for a list of routes (avoids N+1)."""
+ owner_ids = {r.created_by for r in routes if r.created_by}
+ if not owner_ids:
+ return {}
+ return {
+ p.user_id: p
+ for p in session.execute(
+ select(UserProfile).where(UserProfile.user_id.in_(owner_ids))
+ )
+ .scalars()
+ .all()
+ }
+
+
+def _route_to_read(
+ route: Route,
+ *,
+ quality_avg: Any = _UNSET,
+ owner: UserProfile | None = None,
+) -> RouteRead:
"""Serialize a Route to its list-level read schema.
``quality_avg`` defaults to the precomputed value persisted on
``route.route_result.quality_avg`` (written by the background
evaluator). Callers may pass an explicit value (e.g. ``None`` on
create responses) to override.
+
+ ``owner`` is the resolved UserProfile for ``route.created_by``, if
+ any. Callers should pass it in to avoid per-row queries in list
+ contexts (use ``_resolve_owners_batch``).
"""
if quality_avg is _UNSET:
quality_avg = route.route_result.quality_avg if route.route_result else None
@@ -158,6 +221,8 @@ def _route_to_read(route: Route, *, quality_avg: Any = _UNSET) -> RouteRead:
route_observers=[_route_observer_to_read(ro) for ro in route.route_observers],
route_result=_result_to_summary(route.route_result),
quality_avg=quality_avg,
+ created_by=route.created_by,
+ owner=_profile_to_owner(owner) if owner else None,
created_at=route.created_at,
updated_at=route.updated_at,
)
@@ -257,22 +322,27 @@ def list_routes(
max_level = get_max_visibility_level(role)
routes = session.execute(select(Route).order_by(Route.from_label)).scalars().all()
+ visible = [r for r in routes if VISIBILITY_LEVELS.get(r.visibility, 0) <= max_level]
+ owners_by_id = _resolve_owners_batch(session, visible)
filtered = [
- _route_to_read(r)
- for r in routes
- if VISIBILITY_LEVELS.get(r.visibility, 0) <= max_level
+ _route_to_read(
+ r, owner=owners_by_id.get(r.created_by) if r.created_by else None
+ )
+ for r in visible
]
return RouteList(items=filtered, total=len(filtered))
@router.post("", response_model=RouteRead, status_code=201)
def create_route(
- __: RequireOperatorOrAdmin,
+ caller: RequireOperatorOrAdmin,
session: DbSession,
body: RouteCreate,
request: Request,
) -> RouteRead:
"""Create a new route (operator or admin)."""
+ user_id, _ = caller
+ get_or_create_profile(session, user_id, request)
_assert_visibility_within_role(request, body.visibility)
existing = session.execute(
select(Route).where(
@@ -309,6 +379,7 @@ def create_route(
max_path_length=body.max_path_length,
enabled=body.enabled,
reversible=body.reversible,
+ created_by=user_id,
)
session.add(route)
session.flush()
@@ -318,7 +389,8 @@ def create_route(
session.refresh(route)
_reevaluate_route(session, route)
invalidate_routes(request)
- return _route_to_read(route, quality_avg=None)
+ owner = _resolve_owner(session, route.created_by)
+ return _route_to_read(route, quality_avg=None, owner=owner)
@router.get("/{route_id}", response_model=RouteDetail)
@@ -374,7 +446,7 @@ def get_route(
for oid, cnt in contributing.items()
]
- read = _route_to_read(route)
+ read = _route_to_read(route, owner=_resolve_owner(session, route.created_by))
return RouteDetail(
**read.model_dump(),
contributing_observers=contributors,
@@ -566,13 +638,19 @@ def get_route_history(
@router.put("/{route_id}", response_model=RouteRead)
def update_route(
- __: RequireOperatorOrAdmin,
+ caller: RequireOperatorOrAdmin,
session: DbSession,
route_id: str,
body: RouteUpdate,
request: Request,
) -> RouteRead:
- """Update a route (operator or admin)."""
+ """Update a route (operator or admin).
+
+ Operators may only modify routes they created; admins can modify any
+ route. Admins claim ownership of legacy (unowned) routes on edit but
+ do not displace an existing creator.
+ """
+ user_id, _ = caller
route = session.execute(
select(Route).where(Route.id == route_id)
).scalar_one_or_none()
@@ -580,6 +658,11 @@ def update_route(
raise HTTPException(status_code=404, detail="Route not found")
_assert_route_modifiable(request, route)
+ # 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 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
new_to = body.to_label if body.to_label is not None else route.to_label
@@ -636,7 +719,8 @@ def update_route(
session.refresh(route)
_reevaluate_route(session, route)
invalidate_routes(request)
- return _route_to_read(route)
+ owner = _resolve_owner(session, route.created_by)
+ return _route_to_read(route, owner=owner)
@router.delete("/{route_id}", status_code=204)
diff --git a/src/meshcore_hub/common/models/route.py b/src/meshcore_hub/common/models/route.py
index 6cab52c..4fefc94 100644
--- a/src/meshcore_hub/common/models/route.py
+++ b/src/meshcore_hub/common/models/route.py
@@ -103,6 +103,10 @@ class Route(Base, UUIDMixin, TimestampMixin):
server_default="true",
nullable=False,
)
+ created_by: Mapped[Optional[str]] = mapped_column(
+ String(255),
+ nullable=True,
+ )
route_nodes: Mapped[list["RouteNode"]] = relationship(
"RouteNode",
diff --git a/src/meshcore_hub/common/schemas/routes.py b/src/meshcore_hub/common/schemas/routes.py
index 8af0966..b731866 100644
--- a/src/meshcore_hub/common/schemas/routes.py
+++ b/src/meshcore_hub/common/schemas/routes.py
@@ -142,6 +142,17 @@ class RouteUpdate(BaseModel):
return self
+class RouteOwner(BaseModel):
+ """Creator/owner of a route, for display on route cards."""
+
+ user_id: str
+ name: Optional[str] = None
+ callsign: Optional[str] = None
+ profile_id: str
+
+ model_config = {"from_attributes": True}
+
+
class RouteRead(BaseModel):
"""Schema for reading a route (list-level with lightweight result)."""
@@ -169,6 +180,8 @@ class RouteRead(BaseModel):
"back to ``route_result.quality`` for brand-new routes."
),
)
+ created_by: Optional[str] = None
+ owner: Optional[RouteOwner] = None
created_at: datetime
updated_at: datetime
@@ -227,6 +240,8 @@ class RouteDetail(BaseModel):
)
contributing_observers: list[ContributingObserver] = []
recent_matches: list[RecentMatchPath] = []
+ created_by: Optional[str] = None
+ owner: Optional[RouteOwner] = None
created_at: datetime
updated_at: datetime
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx
index 3d35b69..7412fbf 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx
@@ -30,6 +30,8 @@ const ROUTES = {
route_result: { quality: "clear", state: "healthy" },
route_nodes: [],
route_observers: [],
+ created_by: null,
+ owner: null,
},
],
};
@@ -135,3 +137,171 @@ describe("Routes role-gated management", () => {
expect(values).not.toContain("admin");
});
});
+
+describe("Routes per-route ownership gating", () => {
+ function buildConfig(roles: string[], userSub: string) {
+ return makeConfig({
+ oidc_enabled: true,
+ roles,
+ role_names: { admin: "admin", operator: "operator", member: "member" },
+ user: { sub: userSub, name: "Test User" },
+ });
+ }
+
+ afterEach(() => {
+ window.__APP_CONFIG__ = makeConfig();
+ });
+
+ it("hides edit/delete on routes the operator does not own", async () => {
+ const cfg = buildConfig(["operator"], "op-1");
+ window.__APP_CONFIG__ = cfg;
+ const data = {
+ items: [
+ {
+ ...ROUTES.items[0],
+ id: "other",
+ from_label: "OtherRoute",
+ created_by: "different-op",
+ owner: null,
+ },
+ ],
+ };
+ vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
+ if (path === "/api/v1/routes") return data;
+ if (path.match(/\/api\/v1\/routes\/[^/]+$/)) return ROUTE_DETAIL;
+ if (path.includes("/history")) return ROUTE_HISTORY;
+ throw new Error(`Unexpected: ${path}`);
+ });
+
+ renderWithProviders(