Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 1 addition & 1 deletion docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
6 changes: 4 additions & 2 deletions docs/routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).
15 changes: 9 additions & 6 deletions e2e/tests/routes-operator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]');
Expand All @@ -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");
Expand Down
11 changes: 8 additions & 3 deletions e2e/tests/routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]');
Expand Down
112 changes: 98 additions & 14 deletions src/meshcore_hub/api/routes/routes.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -15,6 +16,7 @@
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,
Expand All @@ -34,6 +36,7 @@
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,
Expand All @@ -44,6 +47,7 @@
RouteList,
RouteNodeRead,
RouteObserverRead,
RouteOwner,
RoutePreviewRequest,
RoutePreviewResponse,
RouteRead,
Expand All @@ -52,6 +56,7 @@
)

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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -566,20 +638,31 @@ 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()
if not 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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/meshcore_hub/common/models/route.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading