"Sign in with a passkey" on the login page replaces password and TOTP. User verification is required, so the passkey is two factors on its own; password login stays alongside. Users add, list and remove passkeys under Settings -> Profile after confirming their password. WebAuthn checks (challenge, origin, RP ID, signature, sign count) come from py_webauthn; the browser side uses the native PublicKeyCredential JSON helpers. Off unless WEBAUTHN_ORIGIN is set; its hostname is the RP ID. Local accounts only: a passkey sign-in never asks the directory, so an LDAP user disabled or demoted in AD would keep access and role. Deactivated and permanently locked users are refused. Challenges are 5-minute signed tokens burnt on first use (token_revocation.claim, check and revoke under one lock). The admin "Reset MFA" also removes the user's passkeys. New table passkeys (migration 067). Rules live in app/auth/passkey.py, endpoints under /auth/passkeys in app/routers/auth_passkey.py.
134 lines
5.6 KiB
Python
134 lines
5.6 KiB
Python
"""
|
|
The sidebar hides admin/editor pages from readonly, but the URL does not —
|
|
so the server must refuse what those pages load. This mounts every router,
|
|
signs in as readonly and GETs every route: everything not on the allow-list
|
|
must be 403. A new endpoint that readonly may see goes on the list on purpose.
|
|
|
|
Found this way: /api/v1/groups handed group membership (usernames) to
|
|
readonly, /api/v1/groups/default and /api/v1/policies answered too — the
|
|
Groups / Policies / Auth Providers pages were reachable by typing the URL.
|
|
"""
|
|
import os
|
|
import re
|
|
import sys
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
os.environ.setdefault("JWT_SECRET_KEY", "0" * 64)
|
|
|
|
# The EOL tests park a fake `app.routers.vulnerabilities` in sys.modules for
|
|
# the whole session; scans.py imports real names from it. Take the real one.
|
|
_stub = sys.modules.get("app.routers.vulnerabilities")
|
|
if _stub is not None and not hasattr(_stub, "sync_agent_vulnerabilities"):
|
|
del sys.modules["app.routers.vulnerabilities"]
|
|
|
|
import pytest # noqa: E402
|
|
from fastapi import FastAPI # noqa: E402
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
|
|
from app.auth.dependencies import get_current_user # noqa: E402
|
|
from app.database import get_db # noqa: E402
|
|
from app.models.user import User, UserRole # noqa: E402
|
|
from app.routers import ( # noqa: E402
|
|
advisories, assets, audit, auth, auth_admin, auth_passkey, compliance, groups, https_certificate, igel, intune,
|
|
nessus, notifications, policies, reports, scans, settings, trust_store, vcenter, vulnerabilities,
|
|
)
|
|
|
|
ROUTERS = (auth, auth_admin, auth_passkey, vulnerabilities, nessus, intune, vcenter, igel, advisories, assets,
|
|
policies, scans, settings, trust_store, https_certificate, notifications, groups, reports, audit, compliance)
|
|
|
|
# What a readonly user may GET. Path params are substituted with "1".
|
|
READONLY_MAY_GET = {
|
|
"/auth/providers",
|
|
"/auth/me",
|
|
"/auth/passkeys", # own passkeys only
|
|
"/api/v1/vulnerabilities",
|
|
"/api/v1/vulnerabilities/by-package",
|
|
"/api/v1/vulnerabilities/ai-history",
|
|
"/api/v1/vulnerabilities/reports/dashboard",
|
|
"/api/v1/vulnerabilities/1",
|
|
"/api/v1/vulnerabilities/override/check",
|
|
"/api/v1/vulnerabilities/override/vulnrichment/status/1",
|
|
"/api/v1/vulnerabilities/override/vulnrichment/jobs",
|
|
"/api/v1/vulnerabilities/eol-check/status",
|
|
"/api/v1/vulnerabilities/app-cve-scan/status",
|
|
"/api/v1/vulnerabilities/msrc-scan/status",
|
|
"/api/v1/vulnerabilities/ai-remediation/status",
|
|
"/api/v1/vulnerabilities/1/remediations",
|
|
"/api/v1/vulnerabilities/override/stats",
|
|
"/api/v1/advisories/feeds",
|
|
"/api/v1/advisories/kev-recent",
|
|
"/api/v1/advisories/kev-alerts",
|
|
"/api/v1/assets/1/coverage-gap",
|
|
"/api/v1/assets/1/software",
|
|
"/api/v1/assets/1/containers", # a Docker host's images — rows of the asset list
|
|
"/api/v1/assets",
|
|
# The host picker on the Vulnerabilities page — a readonly user filters by
|
|
# host, so this has to be as open as the list it replaces.
|
|
"/api/v1/assets/names",
|
|
"/api/v1/assets/export",
|
|
"/api/v1/assets/1",
|
|
"/api/v1/assets/1/vulnerabilities",
|
|
"/api/v1/scans/schedules", # dashboard shows the next run to everyone
|
|
"/api/v1/notifications/log", # own rows only (filtered in the handler)
|
|
"/api/v1/groups", # filter dropdowns; membership stripped
|
|
"/api/v1/reports/vulnerabilities/csv",
|
|
"/api/v1/reports/executive-summary/pdf",
|
|
"/api/v1/reports/patching-progress/pdf",
|
|
"/api/v1/reports/compliance/iso27001/pdf",
|
|
"/api/v1/compliance/summary",
|
|
"/api/v1/compliance/assets",
|
|
"/api/v1/compliance/1",
|
|
"/api/v1/compliance/1/1/checks",
|
|
"/api/v1/compliance/impacts/stats",
|
|
"/api/v1/compliance/urs/1",
|
|
"/api/v1/compliance/urs",
|
|
}
|
|
|
|
|
|
def _client(role: UserRole, db=None) -> TestClient:
|
|
app = FastAPI()
|
|
for r in ROUTERS:
|
|
app.include_router(r.router)
|
|
user = User(id=1, username="u", email="u@x", role=role, is_active=True)
|
|
app.dependency_overrides[get_current_user] = lambda: user
|
|
app.dependency_overrides[get_db] = lambda: db or MagicMock()
|
|
return TestClient(app, raise_server_exceptions=False)
|
|
|
|
|
|
def _get_paths():
|
|
for r in ROUTERS:
|
|
for route in r.router.routes:
|
|
if "GET" in (getattr(route, "methods", None) or set()):
|
|
yield re.sub(r"\{[^}]+\}", "1", route.path)
|
|
|
|
|
|
def test_readonly_reaches_exactly_the_allow_list():
|
|
c = _client(UserRole.READONLY)
|
|
reached = {p for p in _get_paths() if c.get(p).status_code != 403}
|
|
assert reached == READONLY_MAY_GET, {
|
|
"open_but_should_be_gated": sorted(reached - READONLY_MAY_GET),
|
|
"gated_but_listed_as_open": sorted(READONLY_MAY_GET - reached),
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/api/v1/groups/default", "/api/v1/policies"])
|
|
def test_editor_or_admin_still_pass(path):
|
|
role = UserRole.ADMIN if "groups" in path else UserRole.EDITOR
|
|
assert _client(role).get(path).status_code != 403
|
|
|
|
|
|
def test_group_membership_only_for_admin():
|
|
# SimpleNamespace, not MagicMock: `name` is a reserved MagicMock kwarg.
|
|
member = SimpleNamespace(id=7, username="alice")
|
|
group = SimpleNamespace(id=1, name="g", description=None, created_at=datetime(2026, 1, 1), users=[member])
|
|
db = MagicMock()
|
|
db.query.return_value.all.return_value = [group]
|
|
|
|
ro = _client(UserRole.READONLY, db).get("/api/v1/groups").json()
|
|
assert ro[0]["members"] == [] and ro[0]["member_count"] == 1
|
|
|
|
admin = _client(UserRole.ADMIN, db).get("/api/v1/groups").json()
|
|
assert admin[0]["members"] == [{"id": 7, "username": "alice"}]
|