Skip to content

Commit 83a936c

Browse files
feat(routes): allow operators to manage routes
Operators can now create, edit, and delete routes (previously admin-only). A user may never scope a route above their own role tier: an operator creating/editing an admin-visibility route is rejected (403 on the visibility value, 404 on touching an existing higher-visibility route), preventing them from creating routes they could then never see or modify. - routes.py: RequireAdmin -> RequireOperatorOrAdmin on create/update/delete; add visibility-cap enforcement helpers reusing the existing resolve_user_role / VISIBILITY_LEVELS ladder - web/app.py: proxy access map admits operator for routes POST/PUT/DELETE - Routes.tsx: canManage gate (admin||operator) on Add/Edit/Delete; visibility <select> filters options by caller tier so operators never see 'admin' - tests: operator-tier coverage (create/update/delete at/below/above level), proxy access-map assertion, vitest role-gating + filtered select - e2e: mint operator session + routes-operator spec - docs: routes.md + auth.md operator/visibility-cap notes
1 parent 5ead0fb commit 83a936c

12 files changed

Lines changed: 437 additions & 61 deletions

File tree

docs/auth.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ User roles are read from the OIDC token's `roles` claim (configurable via `OIDC_
2121
| Role | Config Variable | Default | Description |
2222
|------|----------------|---------|-------------|
2323
| Admin | `OIDC_ROLE_ADMIN` | `admin` | Full write access to all API endpoints through the proxy |
24-
| Operator | `OIDC_ROLE_OPERATOR` | `operator` | Reserved for future use — no endpoint assignments yet |
24+
| Operator | `OIDC_ROLE_OPERATOR` | `operator` | Manage nodes, node tags, adoptions, and routes (create/edit/delete, scoped to the operator visibility tier) |
2525
| Member | `OIDC_ROLE_MEMBER` | `member` | Read-only access (no endpoint assignments) |
2626

2727
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`.
@@ -36,17 +36,21 @@ The proxy uses a hardcoded per-endpoint, per-method mapping in `src/meshcore_hub
3636
|-------------|--------|--------|
3737
| `v1/nodes` | GET | Open |
3838
| `v1/nodes/` | GET | Open |
39-
| `v1/nodes/` | POST, PUT, DELETE | `admin` |
39+
| `v1/nodes/` | POST, PUT, DELETE | `admin`, `operator` |
4040
| `v1/members` | GET | Open |
4141
| `v1/members` | POST, PUT, DELETE | `admin` |
4242
| `v1/messages` | GET | Open |
4343
| `v1/advertisements` | GET | Open |
44+
| `v1/adoptions` | POST, DELETE | `admin`, `operator` |
45+
| `v1/routes` | POST | `admin`, `operator` |
46+
| `v1/routes/` | PUT, DELETE | `admin`, `operator` |
4447
| `v1/dashboard` | GET | Open |
4548
| `v1/trace-paths` | GET | Open |
4649
| `v1/telemetry` | GET | Open |
4750

4851
- **Open** = no authentication required (anonymous OK, works with or without OIDC)
4952
- **`admin`** = requires OIDC enabled + user has the `admin` role
53+
- **`admin`, `operator`** = requires OIDC enabled + user has the `admin` *or* `operator` role
5054
- Method not listed for a matched prefix = denied
5155
- No prefix match = denied
5256

docs/routes.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,13 @@ The collector runs a background thread that re-evaluates every enabled route on
4444

4545
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.
4646

47+
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`.
48+
4749
## Defining routes
4850

4951
Routes are keyed by their `from`/`to` endpoint labels and upserted by that pair. There are two ways to create them:
5052

5153
- **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).
52-
- **API**`POST /api/v1/routes` (admin only) 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.
54+
- **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.
5355

54-
The `/routes` page renders the live status card, the per-day history strip, recent matching transmissions (with observer attribution), and — for admins — inline edit/delete controls.
56+
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.

e2e/global-setup.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,13 @@ export default async function globalSetup(): Promise<void> {
152152
"pw-member@example.com",
153153
"member",
154154
);
155+
const operatorCookie = await mintSessionCookie(
156+
"pw-operator",
157+
"PW Operator",
158+
"pw-operator@example.com",
159+
"operator,member",
160+
);
155161
await writeStorageState(adminCookie, path.join(AUTH_DIR, "admin.json"));
156162
await writeStorageState(memberCookie, path.join(AUTH_DIR, "member.json"));
163+
await writeStorageState(operatorCookie, path.join(AUTH_DIR, "operator.json"));
157164
}

e2e/tests/routes-operator.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { expect, test } from "@playwright/test";
2+
import { OPERATOR_STATE } from "../utils/helpers";
3+
4+
test.use({ storageState: OPERATOR_STATE });
5+
6+
const ROUTE_LABEL = "Op From \u2192 Op To";
7+
8+
test.describe.serial("routes (operator)", () => {
9+
test("operator can manage routes; admin visibility tier is hidden", async ({
10+
page,
11+
}) => {
12+
await page.goto("/routes");
13+
14+
// Operators see the seeded community route and the add button.
15+
await expect(
16+
page.locator(
17+
'[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]',
18+
),
19+
).toBeVisible();
20+
await expect(page.getByTestId("add-route")).toBeVisible();
21+
22+
// The visibility dropdown must NOT offer the admin tier to an operator.
23+
await page.getByTestId("add-route").click();
24+
const modal = page.locator('[data-testid="route-modal"]');
25+
await expect(modal).toBeVisible();
26+
const visibility = page.getByTestId("route-visibility");
27+
await expect(visibility.locator("option[value='admin']")).toHaveCount(0);
28+
await visibility.selectOption("operator");
29+
await expect(visibility).toHaveValue("operator");
30+
31+
await page.getByTestId("route-from").fill("Op From");
32+
await page.getByTestId("route-to").fill("Op To");
33+
34+
await page.getByTestId("route-path-search").fill("Alpha");
35+
await page.getByTestId("node-search-result").first().click();
36+
await page.getByTestId("route-path-search").fill("Bravo");
37+
await page.getByTestId("node-search-result").first().click();
38+
await expect(page.getByTestId("route-path-chip")).toHaveCount(2);
39+
40+
await page.getByTestId("route-save").click();
41+
await expect(modal).toHaveCount(0);
42+
43+
const card = page.locator(
44+
`[data-testid="route-card"][data-route-label="${ROUTE_LABEL}"]`,
45+
);
46+
await expect(card).toBeVisible();
47+
48+
// Operator can edit the route they created.
49+
await card.getByTestId("edit-route").click();
50+
await expect(modal).toBeVisible();
51+
await expect(page.getByTestId("route-from")).toHaveValue("Op From");
52+
await page.getByTestId("route-cancel").click();
53+
54+
// Operator can delete the route they created.
55+
await card.getByTestId("delete-route").click();
56+
const confirm = page.locator("dialog.modal-open");
57+
await expect(confirm).toBeVisible();
58+
await confirm.getByRole("button", { name: "Delete" }).click();
59+
await expect(card).toHaveCount(0);
60+
});
61+
});

e2e/utils/helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const AUTH_DIR = path.resolve(
99
);
1010
export const ADMIN_STATE = path.join(AUTH_DIR, "admin.json");
1111
export const MEMBER_STATE = path.join(AUTH_DIR, "member.json");
12+
export const OPERATOR_STATE = path.join(AUTH_DIR, "operator.json");
1213

1314
export async function expectListLoaded(page: Page): Promise<void> {
1415
await expect(page.getByTestId("list-row").first()).toBeVisible();

src/meshcore_hub/api/routes/routes.py

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from fastapi import APIRouter, HTTPException, Request
77
from sqlalchemy import select
88

9-
from meshcore_hub.api.auth import RequireAdmin, RequireRead
9+
from meshcore_hub.api.auth import RequireOperatorOrAdmin, RequireRead
1010
from meshcore_hub.api.cache import cached, sorted_query_string
1111
from meshcore_hub.api.cache_invalidation import invalidate_routes
1212
from meshcore_hub.api.channel_visibility import (
@@ -64,6 +64,41 @@ def _routes_key_builder(request: Request) -> str:
6464
return f"{request.url.path}:role={role}:{sorted_query_string(request)}"
6565

6666

67+
def _caller_max_visibility_level(request: Request) -> int:
68+
"""Max visibility tier the current caller may set or modify.
69+
70+
Operators can manage routes at or below the operator tier; admins at or
71+
below the admin tier. This is the same role resolution the read handlers
72+
use, so read and write visibility stay consistent.
73+
"""
74+
return get_max_visibility_level(resolve_user_role(request))
75+
76+
77+
def _assert_visibility_within_role(request: Request, visibility: str) -> None:
78+
"""Reject a visibility value above the caller's own role tier.
79+
80+
Stops a user scoping a route to a role they could then never see or
81+
modify (e.g. an operator creating an admin-visibility route).
82+
"""
83+
if VISIBILITY_LEVELS.get(visibility, 0) > _caller_max_visibility_level(request):
84+
raise HTTPException(
85+
status_code=403,
86+
detail="Cannot set route visibility above your own role",
87+
)
88+
89+
90+
def _assert_route_modifiable(request: Request, route: Route) -> None:
91+
"""Reject modifying a route above the caller's visibility tier.
92+
93+
Returns 404 (mirroring the GET detail behaviour) so the existence of a
94+
higher-visibility route is not leaked to lower-privileged callers.
95+
"""
96+
if VISIBILITY_LEVELS.get(route.visibility, 0) > _caller_max_visibility_level(
97+
request
98+
):
99+
raise HTTPException(status_code=404, detail="Route not found")
100+
101+
67102
def _route_node_to_read(rn: RouteNode) -> RouteNodeRead:
68103
return RouteNodeRead(
69104
node_id=rn.node_id,
@@ -232,12 +267,13 @@ def list_routes(
232267

233268
@router.post("", response_model=RouteRead, status_code=201)
234269
def create_route(
235-
__: RequireAdmin,
270+
__: RequireOperatorOrAdmin,
236271
session: DbSession,
237272
body: RouteCreate,
238273
request: Request,
239274
) -> RouteRead:
240-
"""Create a new route (admin only)."""
275+
"""Create a new route (operator or admin)."""
276+
_assert_visibility_within_role(request, body.visibility)
241277
existing = session.execute(
242278
select(Route).where(
243279
Route.from_label == body.from_label,
@@ -530,18 +566,19 @@ def get_route_history(
530566

531567
@router.put("/{route_id}", response_model=RouteRead)
532568
def update_route(
533-
__: RequireAdmin,
569+
__: RequireOperatorOrAdmin,
534570
session: DbSession,
535571
route_id: str,
536572
body: RouteUpdate,
537573
request: Request,
538574
) -> RouteRead:
539-
"""Update a route (admin only)."""
575+
"""Update a route (operator or admin)."""
540576
route = session.execute(
541577
select(Route).where(Route.id == route_id)
542578
).scalar_one_or_none()
543579
if not route:
544580
raise HTTPException(status_code=404, detail="Route not found")
581+
_assert_route_modifiable(request, route)
545582

546583
if body.from_label is not None or body.to_label is not None:
547584
new_from = body.from_label if body.from_label is not None else route.from_label
@@ -564,6 +601,7 @@ def update_route(
564601
if body.description is not None:
565602
route.description = body.description
566603
if body.visibility is not None:
604+
_assert_visibility_within_role(request, body.visibility)
567605
route.visibility = body.visibility
568606
if body.match_width is not None:
569607
route.match_width = body.match_width
@@ -603,17 +641,18 @@ def update_route(
603641

604642
@router.delete("/{route_id}", status_code=204)
605643
def delete_route(
606-
__: RequireAdmin,
644+
__: RequireOperatorOrAdmin,
607645
session: DbSession,
608646
route_id: str,
609647
request: Request,
610648
) -> None:
611-
"""Delete a route (admin only)."""
649+
"""Delete a route (operator or admin)."""
612650
route = session.execute(
613651
select(Route).where(Route.id == route_id)
614652
).scalar_one_or_none()
615653
if not route:
616654
raise HTTPException(status_code=404, detail="Route not found")
655+
_assert_route_modifiable(request, route)
617656
session.delete(route)
618657
session.commit()
619658
invalidate_routes(request)

src/meshcore_hub/web/app.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,12 +166,12 @@ def _build_endpoint_access(
166166
},
167167
"v1/routes": {
168168
"GET": _OPEN,
169-
"POST": frozenset({role_admin}),
169+
"POST": operator_admin,
170170
},
171171
"v1/routes/": {
172172
"GET": _OPEN,
173-
"PUT": frozenset({role_admin}),
174-
"DELETE": frozenset({role_admin}),
173+
"PUT": operator_admin,
174+
"DELETE": operator_admin,
175175
"POST": _OPEN,
176176
},
177177
}

src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { screen, waitFor } from "@testing-library/react";
2-
import { describe, expect, it, vi } from "vitest";
1+
import { fireEvent, screen, waitFor } from "@testing-library/react";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
33

44
vi.mock("@/components/charts/Charts", () => ({
55
ActivityChart: () => null,
@@ -11,6 +11,7 @@ vi.mock("@/components/charts/Charts", () => ({
1111

1212
import { RoutesPage as Routes } from "@/pages/Routes";
1313
import { renderWithProviders } from "@/test/renderWithProviders";
14+
import { makeConfig } from "@/test/makeConfig";
1415
import * as api from "@/utils/api";
1516

1617
const ROUTES = {
@@ -76,3 +77,61 @@ describe("Routes", () => {
7677
});
7778
});
7879
});
80+
81+
describe("Routes role-gated management", () => {
82+
// hasRole() reads window.__APP_CONFIG__ directly (not the React context),
83+
// so we must assign the global to simulate an authenticated session.
84+
function setRoles(roles: string[]) {
85+
window.__APP_CONFIG__ = makeConfig({
86+
oidc_enabled: true,
87+
roles,
88+
role_names: { admin: "admin", operator: "operator", member: "member" },
89+
});
90+
}
91+
92+
afterEach(() => {
93+
window.__APP_CONFIG__ = makeConfig();
94+
});
95+
96+
it("hides the add button from an unprivileged user", async () => {
97+
setRoles(["member"]);
98+
mockRoutesApi();
99+
renderWithProviders(<Routes />);
100+
await waitFor(() => {
101+
expect(screen.getAllByText("NodeA").length).toBeGreaterThanOrEqual(1);
102+
});
103+
expect(screen.queryByTestId("add-route")).toBeNull();
104+
});
105+
106+
it("shows the add button to an operator", async () => {
107+
setRoles(["operator"]);
108+
mockRoutesApi();
109+
renderWithProviders(<Routes />);
110+
expect(await screen.findByTestId("add-route")).toBeInTheDocument();
111+
});
112+
113+
it("offers all visibility tiers to an admin", async () => {
114+
setRoles(["admin"]);
115+
mockRoutesApi();
116+
renderWithProviders(<Routes />);
117+
fireEvent.click(await screen.findByTestId("add-route"));
118+
const select = (await screen.findByTestId(
119+
"route-visibility",
120+
)) as HTMLSelectElement;
121+
const values = Array.from(select.options).map((o) => o.value);
122+
expect(values).toEqual(["community", "member", "operator", "admin"]);
123+
});
124+
125+
it("hides the admin tier from an operator", async () => {
126+
setRoles(["operator"]);
127+
mockRoutesApi();
128+
renderWithProviders(<Routes />);
129+
fireEvent.click(await screen.findByTestId("add-route"));
130+
const select = (await screen.findByTestId(
131+
"route-visibility",
132+
)) as HTMLSelectElement;
133+
const values = Array.from(select.options).map((o) => o.value);
134+
expect(values).toEqual(["community", "member", "operator"]);
135+
expect(values).not.toContain("admin");
136+
});
137+
});

0 commit comments

Comments
 (0)