diff --git a/docs/auth.md b/docs/auth.md index 7324c41..ec976e5 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -21,7 +21,7 @@ User roles are read from the OIDC token's `roles` claim (configurable via `OIDC_ | Role | Config Variable | Default | Description | |------|----------------|---------|-------------| | Admin | `OIDC_ROLE_ADMIN` | `admin` | Full write access to all API endpoints through the proxy | -| Operator | `OIDC_ROLE_OPERATOR` | `operator` | 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 diff --git a/docs/routes.md b/docs/routes.md index a17ebdf..b702851 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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. diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 649017b..83473cc 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -152,6 +152,13 @@ export default async function globalSetup(): Promise { "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")); } diff --git a/e2e/tests/routes-operator.spec.ts b/e2e/tests/routes-operator.spec.ts new file mode 100644 index 0000000..23f31ee --- /dev/null +++ b/e2e/tests/routes-operator.spec.ts @@ -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); + }); +}); diff --git a/e2e/utils/helpers.ts b/e2e/utils/helpers.ts index 2bd023e..efc18d1 100644 --- a/e2e/utils/helpers.ts +++ b/e2e/utils/helpers.ts @@ -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 { await expect(page.getByTestId("list-row").first()).toBeVisible(); diff --git a/src/meshcore_hub/api/routes/routes.py b/src/meshcore_hub/api/routes/routes.py index 2d0f0cd..8066163 100644 --- a/src/meshcore_hub/api/routes/routes.py +++ b/src/meshcore_hub/api/routes/routes.py @@ -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) diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index 1ec54d0..c3bd464 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -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, }, } diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx index 3cc7736..3d35b69 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx @@ -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(); + 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(); + expect(await screen.findByTestId("add-route")).toBeInTheDocument(); + }); + + it("offers all visibility tiers to an admin", async () => { + setRoles(["admin"]); + mockRoutesApi(); + renderWithProviders(); + 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(); + 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"); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx index 4e1ddf4..bbc3998 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx @@ -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 = { @@ -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({ )} - {isAdmin && ( + {canManage && (
@@ -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 && } - {isAdmin && ( + {canManage && (