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
This commit is contained in:
Louis King
2026-07-24 20:59:25 +01:00
parent 5ead0fbad3
commit 83a936cd33
12 changed files with 437 additions and 61 deletions
+6 -2
View File
@@ -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` | Reserved for future use — no endpoint assignments yet |
| Operator | `OIDC_ROLE_OPERATOR` | `operator` | Manage nodes, node tags, adoptions, and routes (create/edit/delete, scoped to the operator visibility tier) |
| 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`.
@@ -36,17 +36,21 @@ The proxy uses a hardcoded per-endpoint, per-method mapping in `src/meshcore_hub
|-------------|--------|--------|
| `v1/nodes` | GET | Open |
| `v1/nodes/` | GET | Open |
| `v1/nodes/` | POST, PUT, DELETE | `admin` |
| `v1/nodes/` | POST, PUT, DELETE | `admin`, `operator` |
| `v1/members` | GET | Open |
| `v1/members` | POST, PUT, DELETE | `admin` |
| `v1/messages` | GET | Open |
| `v1/advertisements` | GET | Open |
| `v1/adoptions` | POST, DELETE | `admin`, `operator` |
| `v1/routes` | POST | `admin`, `operator` |
| `v1/routes/` | PUT, DELETE | `admin`, `operator` |
| `v1/dashboard` | GET | Open |
| `v1/trace-paths` | GET | Open |
| `v1/telemetry` | GET | Open |
- **Open** = no authentication required (anonymous OK, works with or without OIDC)
- **`admin`** = requires OIDC enabled + user has the `admin` role
- **`admin`, `operator`** = requires OIDC enabled + user has the `admin` *or* `operator` role
- Method not listed for a matched prefix = denied
- No prefix match = denied
+4 -2
View File
@@ -44,11 +44,13 @@ 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`.
## Defining routes
Routes are keyed by their `from`/`to` endpoint labels and upserted by that pair. There are two ways to create them:
- **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` (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.
- **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 admins — inline edit/delete controls.
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.
+7
View File
@@ -152,6 +152,13 @@ export default async function globalSetup(): Promise<void> {
"pw-member@example.com",
"member",
);
const operatorCookie = await mintSessionCookie(
"pw-operator",
"PW Operator",
"pw-operator@example.com",
"operator,member",
);
await writeStorageState(adminCookie, path.join(AUTH_DIR, "admin.json"));
await writeStorageState(memberCookie, path.join(AUTH_DIR, "member.json"));
await writeStorageState(operatorCookie, path.join(AUTH_DIR, "operator.json"));
}
+61
View File
@@ -0,0 +1,61 @@
import { expect, test } from "@playwright/test";
import { OPERATOR_STATE } from "../utils/helpers";
test.use({ storageState: OPERATOR_STATE });
const ROUTE_LABEL = "Op From \u2192 Op To";
test.describe.serial("routes (operator)", () => {
test("operator can manage routes; admin visibility tier is hidden", async ({
page,
}) => {
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();
await expect(page.getByTestId("add-route")).toBeVisible();
// 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"]');
await expect(modal).toBeVisible();
const visibility = page.getByTestId("route-visibility");
await expect(visibility.locator("option[value='admin']")).toHaveCount(0);
await visibility.selectOption("operator");
await expect(visibility).toHaveValue("operator");
await page.getByTestId("route-from").fill("Op From");
await page.getByTestId("route-to").fill("Op To");
await page.getByTestId("route-path-search").fill("Alpha");
await page.getByTestId("node-search-result").first().click();
await page.getByTestId("route-path-search").fill("Bravo");
await page.getByTestId("node-search-result").first().click();
await expect(page.getByTestId("route-path-chip")).toHaveCount(2);
await page.getByTestId("route-save").click();
await expect(modal).toHaveCount(0);
const card = page.locator(
`[data-testid="route-card"][data-route-label="${ROUTE_LABEL}"]`,
);
await expect(card).toBeVisible();
// Operator can edit the route they created.
await card.getByTestId("edit-route").click();
await expect(modal).toBeVisible();
await expect(page.getByTestId("route-from")).toHaveValue("Op From");
await page.getByTestId("route-cancel").click();
// Operator can delete the route they created.
await card.getByTestId("delete-route").click();
const confirm = page.locator("dialog.modal-open");
await expect(confirm).toBeVisible();
await confirm.getByRole("button", { name: "Delete" }).click();
await expect(card).toHaveCount(0);
});
});
+1
View File
@@ -9,6 +9,7 @@ const AUTH_DIR = path.resolve(
);
export const ADMIN_STATE = path.join(AUTH_DIR, "admin.json");
export const MEMBER_STATE = path.join(AUTH_DIR, "member.json");
export const OPERATOR_STATE = path.join(AUTH_DIR, "operator.json");
export async function expectListLoaded(page: Page): Promise<void> {
await expect(page.getByTestId("list-row").first()).toBeVisible();
+46 -7
View File
@@ -6,7 +6,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from sqlalchemy import select
from meshcore_hub.api.auth import RequireAdmin, RequireRead
from meshcore_hub.api.auth import RequireOperatorOrAdmin, RequireRead
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 (
@@ -64,6 +64,41 @@ def _routes_key_builder(request: Request) -> str:
return f"{request.url.path}:role={role}:{sorted_query_string(request)}"
def _caller_max_visibility_level(request: Request) -> int:
"""Max visibility tier the current caller may set or modify.
Operators can manage routes at or below the operator tier; admins at or
below the admin tier. This is the same role resolution the read handlers
use, so read and write visibility stay consistent.
"""
return get_max_visibility_level(resolve_user_role(request))
def _assert_visibility_within_role(request: Request, visibility: str) -> None:
"""Reject a visibility value above the caller's own role tier.
Stops a user scoping a route to a role they could then never see or
modify (e.g. an operator creating an admin-visibility route).
"""
if VISIBILITY_LEVELS.get(visibility, 0) > _caller_max_visibility_level(request):
raise HTTPException(
status_code=403,
detail="Cannot set route visibility above your own role",
)
def _assert_route_modifiable(request: Request, route: Route) -> None:
"""Reject modifying a route above the caller's visibility tier.
Returns 404 (mirroring the GET detail behaviour) so the existence of a
higher-visibility route is not leaked to lower-privileged callers.
"""
if VISIBILITY_LEVELS.get(route.visibility, 0) > _caller_max_visibility_level(
request
):
raise HTTPException(status_code=404, detail="Route not found")
def _route_node_to_read(rn: RouteNode) -> RouteNodeRead:
return RouteNodeRead(
node_id=rn.node_id,
@@ -232,12 +267,13 @@ def list_routes(
@router.post("", response_model=RouteRead, status_code=201)
def create_route(
__: RequireAdmin,
__: RequireOperatorOrAdmin,
session: DbSession,
body: RouteCreate,
request: Request,
) -> RouteRead:
"""Create a new route (admin only)."""
"""Create a new route (operator or admin)."""
_assert_visibility_within_role(request, body.visibility)
existing = session.execute(
select(Route).where(
Route.from_label == body.from_label,
@@ -530,18 +566,19 @@ def get_route_history(
@router.put("/{route_id}", response_model=RouteRead)
def update_route(
__: RequireAdmin,
__: RequireOperatorOrAdmin,
session: DbSession,
route_id: str,
body: RouteUpdate,
request: Request,
) -> RouteRead:
"""Update a route (admin only)."""
"""Update a route (operator or admin)."""
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)
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
@@ -564,6 +601,7 @@ def update_route(
if body.description is not None:
route.description = body.description
if body.visibility is not None:
_assert_visibility_within_role(request, body.visibility)
route.visibility = body.visibility
if body.match_width is not None:
route.match_width = body.match_width
@@ -603,17 +641,18 @@ def update_route(
@router.delete("/{route_id}", status_code=204)
def delete_route(
__: RequireAdmin,
__: RequireOperatorOrAdmin,
session: DbSession,
route_id: str,
request: Request,
) -> None:
"""Delete a route (admin only)."""
"""Delete a route (operator or admin)."""
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)
session.delete(route)
session.commit()
invalidate_routes(request)
+3 -3
View File
@@ -166,12 +166,12 @@ def _build_endpoint_access(
},
"v1/routes": {
"GET": _OPEN,
"POST": frozenset({role_admin}),
"POST": operator_admin,
},
"v1/routes/": {
"GET": _OPEN,
"PUT": frozenset({role_admin}),
"DELETE": frozenset({role_admin}),
"PUT": operator_admin,
"DELETE": operator_admin,
"POST": _OPEN,
},
}
@@ -1,5 +1,5 @@
import { screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/components/charts/Charts", () => ({
ActivityChart: () => null,
@@ -11,6 +11,7 @@ vi.mock("@/components/charts/Charts", () => ({
import { RoutesPage as Routes } from "@/pages/Routes";
import { renderWithProviders } from "@/test/renderWithProviders";
import { makeConfig } from "@/test/makeConfig";
import * as api from "@/utils/api";
const ROUTES = {
@@ -76,3 +77,61 @@ describe("Routes", () => {
});
});
});
describe("Routes role-gated management", () => {
// hasRole() reads window.__APP_CONFIG__ directly (not the React context),
// so we must assign the global to simulate an authenticated session.
function setRoles(roles: string[]) {
window.__APP_CONFIG__ = makeConfig({
oidc_enabled: true,
roles,
role_names: { admin: "admin", operator: "operator", member: "member" },
});
}
afterEach(() => {
window.__APP_CONFIG__ = makeConfig();
});
it("hides the add button from an unprivileged user", async () => {
setRoles(["member"]);
mockRoutesApi();
renderWithProviders(<Routes />);
await waitFor(() => {
expect(screen.getAllByText("NodeA").length).toBeGreaterThanOrEqual(1);
});
expect(screen.queryByTestId("add-route")).toBeNull();
});
it("shows the add button to an operator", async () => {
setRoles(["operator"]);
mockRoutesApi();
renderWithProviders(<Routes />);
expect(await screen.findByTestId("add-route")).toBeInTheDocument();
});
it("offers all visibility tiers to an admin", async () => {
setRoles(["admin"]);
mockRoutesApi();
renderWithProviders(<Routes />);
fireEvent.click(await screen.findByTestId("add-route"));
const select = (await screen.findByTestId(
"route-visibility",
)) as HTMLSelectElement;
const values = Array.from(select.options).map((o) => o.value);
expect(values).toEqual(["community", "member", "operator", "admin"]);
});
it("hides the admin tier from an operator", async () => {
setRoles(["operator"]);
mockRoutesApi();
renderWithProviders(<Routes />);
fireEvent.click(await screen.findByTestId("add-route"));
const select = (await screen.findByTestId(
"route-visibility",
)) as HTMLSelectElement;
const values = Array.from(select.options).map((o) => o.value);
expect(values).toEqual(["community", "member", "operator"]);
expect(values).not.toContain("admin");
});
});
@@ -158,6 +158,16 @@ const PATH_MAX = 5;
const PATH_HEAD = 2;
const PATH_TAIL = 2;
/** Max route-visibility tier the current user may set or modify. Mirrors the
* backend caller cap so a user can never scope a route above their own role
* (which would make it invisible/unmodifiable to them). */
function maxVisibilityLevel(): number {
if (hasRole("admin")) return 3;
if (hasRole("operator")) return 2;
if (hasRole("member")) return 1;
return 0;
}
function qualityDot(quality: string, enabled: boolean): string {
if (!enabled) return "\u25CC";
const dots: Record<string, string> = {
@@ -485,14 +495,14 @@ function DetailContent({
function RouteCard({
route,
isAdmin,
canManage,
packetsEnabled,
onEdit,
onDelete,
onNavigate,
}: {
route: RouteItem;
isAdmin: boolean;
canManage: boolean;
packetsEnabled: boolean;
onEdit: () => void;
onDelete: () => void;
@@ -580,7 +590,7 @@ function RouteCard({
<span className="loading loading-spinner loading-sm opacity-50"></span>
</div>
)}
{isAdmin && (
{canManage && (
<div className="flex gap-2 mt-auto pt-2">
<button
className="btn btn-xs btn-outline"
@@ -819,10 +829,13 @@ function RouteModal({
value={visibility}
onChange={(e) => setVisibility(e.target.value)}
>
<option value="community">community</option>
<option value="member">member</option>
<option value="operator">operator</option>
<option value="admin">admin</option>
{VISIBILITY_ORDER.map((vis) =>
VISIBILITY_ORDER.indexOf(vis) <= maxVisibilityLevel() ? (
<option key={vis} value={vis}>
{vis}
</option>
) : null,
)}
</select>
</div>
<div>
@@ -1145,7 +1158,7 @@ export function RoutesPage() {
const navigate = useNavigate();
const config = useAppConfig();
const packetsEnabled = config.features?.packets !== false;
const isAdmin = hasRole("admin");
const canManage = hasRole("admin") || hasRole("operator");
usePageTitle("routes.title");
const queryClient = useQueryClient();
@@ -1468,7 +1481,7 @@ export function RoutesPage() {
{error && <ErrorAlert message={error} />}
{isAdmin && (
{canManage && (
<div className="flex justify-end mb-4">
<button
className="btn btn-primary btn-sm"
@@ -1503,7 +1516,7 @@ export function RoutesPage() {
<RouteCard
key={r.id}
route={r}
isAdmin={isAdmin}
canManage={canManage}
packetsEnabled={packetsEnabled}
onEdit={() => openEditModal(r)}
onDelete={() => openDeleteModal(r)}
+8 -5
View File
@@ -1552,7 +1552,7 @@ class TestMutationInvalidationIntegration:
"node_public_keys": [n1.public_key, n2.public_key],
"match_width": 2,
},
headers={"X-User-Roles": "admin"},
headers={"X-User-Id": "test-admin", "X-User-Roles": "admin"},
)
assert resp.status_code == 201
mock_cache.delete.assert_any_call("/api/v1/routes")
@@ -1585,7 +1585,7 @@ class TestMutationInvalidationIntegration:
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"from_label": "NewFrom", "to_label": "NewTo"},
headers={"X-User-Roles": "admin"},
headers={"X-User-Id": "test-admin", "X-User-Roles": "admin"},
)
assert resp.status_code == 200
mock_cache.delete.assert_any_call("/api/v1/routes")
@@ -1614,7 +1614,7 @@ class TestMutationInvalidationIntegration:
mock_cache = self._install_mock_cache(client_no_auth)
resp = client_no_auth.delete(
f"/api/v1/routes/{route.id}",
headers={"X-User-Roles": "admin"},
headers={"X-User-Id": "test-admin", "X-User-Roles": "admin"},
)
assert resp.status_code == 204
mock_cache.delete.assert_any_call("/api/v1/routes")
@@ -1782,7 +1782,10 @@ class TestMutationVisibilityThroughHttpCache:
client_no_auth.app.state.redis_cache_ttl = 30
# 1) Initial GET — populates cache and returns an ETag.
first = client_no_auth.get("/api/v1/routes", headers={"X-User-Roles": "admin"})
first = client_no_auth.get(
"/api/v1/routes",
headers={"X-User-Id": "test-admin", "X-User-Roles": "admin"},
)
assert first.status_code == 200
assert first.headers["x-cache"] == "MISS"
assert first.headers["cache-control"] == "private, no-cache"
@@ -1807,7 +1810,7 @@ class TestMutationVisibilityThroughHttpCache:
mut = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"from_label": "NewOrigin", "to_label": "NewDest"},
headers={"X-User-Roles": "admin"},
headers={"X-User-Id": "test-admin", "X-User-Roles": "admin"},
)
assert mut.status_code == 200
# Mutations are always no-store.
+193 -30
View File
@@ -12,6 +12,10 @@ from meshcore_hub.common.models import (
RouteNode,
)
ADMIN_HEADERS = {"X-User-Id": "test-admin", "X-User-Roles": "admin"}
OPERATOR_HEADERS = {"X-User-Id": "test-operator", "X-User-Roles": "operator"}
MEMBER_HEADERS = {"X-User-Id": "test-member", "X-User-Roles": "member"}
def _make_node(session, public_key: str, name: str | None = None) -> Node:
node = Node(public_key=public_key, name=name, first_seen=datetime.now(timezone.utc))
@@ -91,7 +95,7 @@ class TestListRoutes:
)
api_db_session.commit()
resp = client_no_auth.get("/api/v1/routes", headers={"X-User-Roles": "admin"})
resp = client_no_auth.get("/api/v1/routes", headers=ADMIN_HEADERS)
assert resp.status_code == 200
labels = [r["from_label"] for r in resp.json()["items"]]
assert "Public" in labels
@@ -111,7 +115,7 @@ class TestCreateRoute:
"node_public_keys": [n.public_key for n in nodes],
"match_width": 1,
},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 201
data = resp.json()
@@ -133,7 +137,7 @@ class TestCreateRoute:
"node_public_keys": [n.public_key for n in nodes],
"reversible": False,
},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 201
assert resp.json()["reversible"] is False
@@ -150,7 +154,7 @@ class TestCreateRoute:
"to_label": "End",
"node_public_keys": [n.public_key for n in nodes],
},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 409
@@ -165,7 +169,7 @@ class TestCreateRoute:
"to_label": "B",
"node_public_keys": [node.public_key],
},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 422
@@ -180,7 +184,7 @@ class TestCreateRoute:
"to_label": "B",
"node_public_keys": [node.public_key, node.public_key],
},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 422
@@ -197,25 +201,40 @@ class TestCreateRoute:
"packet_count_threshold": 5,
"clear_threshold": 3,
},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 422
def test_non_admin_rejected(self, client_with_auth, api_db_session):
def test_member_rejected(self, client_no_auth, api_db_session):
nodes = _sample_nodes(api_db_session)
api_db_session.commit()
resp = client_with_auth.post(
resp = client_no_auth.post(
"/api/v1/routes",
json={
"from_label": "A",
"to_label": "B",
"node_public_keys": [n.public_key for n in nodes],
},
headers={"Authorization": "Bearer test-read-key"},
headers=MEMBER_HEADERS,
)
assert resp.status_code == 403
def test_no_identity_rejected(self, client_no_auth, api_db_session):
"""A request without an X-User-Id identity cannot create routes."""
nodes = _sample_nodes(api_db_session)
api_db_session.commit()
resp = client_no_auth.post(
"/api/v1/routes",
json={
"from_label": "A",
"to_label": "B",
"node_public_keys": [n.public_key for n in nodes],
},
)
assert resp.status_code == 401
class TestGetRouteDetail:
def test_detail_shape(self, client_no_auth, api_db_session):
@@ -453,7 +472,7 @@ class TestRouteQualityAvg:
"to_label": "Route",
"node_public_keys": [n.public_key for n in nodes],
},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 201
assert resp.json()["quality_avg"] is None
@@ -489,7 +508,7 @@ class TestRouteQualityAvg:
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"description": "now with description"},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["quality_avg"] == "failing"
@@ -515,7 +534,7 @@ class TestUpdateRoute:
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"from_label": "NewFrom", "to_label": "NewTo"},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
@@ -544,7 +563,7 @@ class TestUpdateRoute:
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"node_public_keys": [nodes[0].public_key, new_node.public_key]},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
@@ -606,7 +625,7 @@ class TestUpdateRoute:
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"packet_count_threshold": 3, "clear_threshold": 6},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
@@ -662,7 +681,7 @@ class TestUpdateRoute:
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"description": "still off"},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
assert (
@@ -678,14 +697,14 @@ class TestDeleteRoute:
resp = client_no_auth.delete(
f"/api/v1/routes/{route.id}",
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 204
def test_not_found(self, client_no_auth):
resp = client_no_auth.delete(
"/api/v1/routes/nonexistent",
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 404
@@ -812,7 +831,7 @@ class TestRouteHistory:
resp = client_no_auth.get(
f"/api/v1/routes/{route.id}/history",
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
@@ -954,7 +973,7 @@ class TestUpdateRouteFields:
"enabled": False,
"reversible": False,
},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
@@ -978,7 +997,7 @@ class TestUpdateRouteFields:
resp = client_no_auth.put(
f"/api/v1/routes/{other.id}",
json={"from_label": "OldFrom", "to_label": "OldTo"},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 409
@@ -990,7 +1009,7 @@ class TestUpdateRouteFields:
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"observer_public_keys": [obs.public_key]},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
@@ -1014,14 +1033,14 @@ class TestUpdateRouteFields:
client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"observer_public_keys": [obs.public_key]},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
# Clear with an explicit empty list.
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"observer_public_keys": []},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["route_observers"] == []
@@ -1029,7 +1048,7 @@ class TestUpdateRouteFields:
# Confirm persistence via a fresh GET.
detail = client_no_auth.get(
f"/api/v1/routes/{route.id}",
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
).json()
assert detail["route_observers"] == []
@@ -1042,7 +1061,7 @@ class TestUpdateRouteFields:
json={
"node_public_keys": ["ff" + "0" * 62, "ee" + "0" * 62],
},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 400
@@ -1050,7 +1069,7 @@ class TestUpdateRouteFields:
resp = client_no_auth.put(
"/api/v1/routes/nonexistent",
json={"description": "x"},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 404
@@ -1081,7 +1100,7 @@ class TestGetRouteVisibility:
resp = client_no_auth.get(
f"/api/v1/routes/{route.id}",
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
@@ -1132,7 +1151,7 @@ class TestCreateWithObservers:
"node_public_keys": [n.public_key for n in nodes],
"observer_public_keys": [obs.public_key],
},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 201
data = resp.json()
@@ -1281,7 +1300,7 @@ class TestPrecomputedRecentMatches:
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"description": "trigger reeval"},
headers={"X-User-Roles": "admin"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
@@ -1297,3 +1316,147 @@ class TestPrecomputedRecentMatches:
assert len(rows) == 1
assert rows[0].first_position == 1
assert rows[0].last_position == 2
class TestRouteOperatorPermissions:
"""Operators may manage routes but cannot scope above their own role."""
def test_operator_create_at_own_level(self, client_no_auth, api_db_session):
nodes = _sample_nodes(api_db_session)
api_db_session.commit()
resp = client_no_auth.post(
"/api/v1/routes",
json={
"from_label": "Op",
"to_label": "End",
"visibility": "operator",
"node_public_keys": [n.public_key for n in nodes],
},
headers=OPERATOR_HEADERS,
)
assert resp.status_code == 201
assert resp.json()["visibility"] == "operator"
def test_operator_create_below_level(self, client_no_auth, api_db_session):
nodes = _sample_nodes(api_db_session)
api_db_session.commit()
resp = client_no_auth.post(
"/api/v1/routes",
json={
"from_label": "Op",
"to_label": "End",
"visibility": "community",
"node_public_keys": [n.public_key for n in nodes],
},
headers=OPERATOR_HEADERS,
)
assert resp.status_code == 201
def test_operator_create_above_level_rejected(self, client_no_auth, api_db_session):
nodes = _sample_nodes(api_db_session)
api_db_session.commit()
resp = client_no_auth.post(
"/api/v1/routes",
json={
"from_label": "Op",
"to_label": "End",
"visibility": "admin",
"node_public_keys": [n.public_key for n in nodes],
},
headers=OPERATOR_HEADERS,
)
assert resp.status_code == 403
def test_admin_create_admin_visibility(self, client_no_auth, api_db_session):
"""Admins can still set the highest visibility tier."""
nodes = _sample_nodes(api_db_session)
api_db_session.commit()
resp = client_no_auth.post(
"/api/v1/routes",
json={
"from_label": "Adm",
"to_label": "End",
"visibility": "admin",
"node_public_keys": [n.public_key for n in nodes],
},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 201
assert resp.json()["visibility"] == "admin"
def test_operator_update_own_level(self, client_no_auth, api_db_session):
route = Route(from_label="Op", to_label="End", visibility="operator")
api_db_session.add(route)
api_db_session.commit()
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"description": "edited by operator"},
headers=OPERATOR_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["description"] == "edited by operator"
def test_operator_update_community_route(self, client_no_auth, api_db_session):
route = Route(from_label="Pub", to_label="End", visibility="community")
api_db_session.add(route)
api_db_session.commit()
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"description": "edited"},
headers=OPERATOR_HEADERS,
)
assert resp.status_code == 200
def test_operator_update_admin_route_404(self, client_no_auth, api_db_session):
route = Route(from_label="Secret", to_label="End", visibility="admin")
api_db_session.add(route)
api_db_session.commit()
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"description": "attempt"},
headers=OPERATOR_HEADERS,
)
assert resp.status_code == 404
def test_operator_escalate_visibility_rejected(
self, client_no_auth, api_db_session
):
route = Route(from_label="Op", to_label="End", visibility="operator")
api_db_session.add(route)
api_db_session.commit()
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"visibility": "admin"},
headers=OPERATOR_HEADERS,
)
assert resp.status_code == 403
def test_operator_delete_own_level(self, client_no_auth, api_db_session):
route = Route(from_label="Op", to_label="End", visibility="operator")
api_db_session.add(route)
api_db_session.commit()
resp = client_no_auth.delete(
f"/api/v1/routes/{route.id}",
headers=OPERATOR_HEADERS,
)
assert resp.status_code == 204
def test_operator_delete_admin_route_404(self, client_no_auth, api_db_session):
route = Route(from_label="Secret", to_label="End", visibility="admin")
api_db_session.add(route)
api_db_session.commit()
resp = client_no_auth.delete(
f"/api/v1/routes/{route.id}",
headers=OPERATOR_HEADERS,
)
assert resp.status_code == 404
+24
View File
@@ -293,6 +293,30 @@ class TestCheckApiAccess:
"v1/packet-groups/abc123", "GET", False, frozenset(), mapping=mapping
)
def test_built_mapping_admits_operator_for_routes(self) -> None:
"""Operators (not just admins) may create/update/delete routes."""
mapping = _build_endpoint_access(role_admin="admin")
operator = frozenset({"operator"})
# Collection POST (create) + item PUT/DELETE (prefix-matched).
assert check_api_access(
"v1/routes", "POST", True, operator, user_id="op-1", mapping=mapping
)
assert check_api_access(
"v1/routes/abc", "PUT", True, operator, user_id="op-1", mapping=mapping
)
assert check_api_access(
"v1/routes/abc", "DELETE", True, operator, user_id="op-1", mapping=mapping
)
# Members are still denied route writes.
assert not check_api_access(
"v1/routes",
"POST",
True,
frozenset({"member"}),
user_id="m-1",
mapping=mapping,
)
class TestRadioConfigSettingsFallback:
"""Tests that radio config falls back to settings when params are None."""