mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-03 23:42:41 +02:00
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:
+6
-2
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user